package httpapi import ( "context" "errors" "net/http" "net/url" "strconv" "strings" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/usecase" "github.com/gin-gonic/gin" ) type AdminAuthenticator interface { AuthenticateAdmin( context.Context, string, ) (domain.AuthPrincipal, error) } type DeviceAuthenticator interface { AuthenticateAccessToken( context.Context, string, ) (domain.AuthPrincipal, error) } type BuyerTokenService interface { LoginBuyerDevice( context.Context, usecase.LoginBuyerDeviceCommand, ) (usecase.AccessTokenResult, error) } func NewPublicAuthRegistrar( service BuyerTokenService, limiter authcommon.AttemptLimiter, ) (RouteRegistrar, error) { if service == nil { return nil, errors.New("buyer token service is required") } if limiter == nil { return nil, errors.New("buyer login limiter is required") } handler := &authHandlers{service: service, limiter: limiter} return func(routes gin.IRoutes) error { routes.POST("/api/v1/auth/token", handler.issueBuyerToken) return nil }, nil } type authHandlers struct { service BuyerTokenService limiter authcommon.AttemptLimiter } func (h *authHandlers) issueBuyerToken(ctx *gin.Context) { if !hasMediaType(ctx, "application/json") { writePublicError( ctx, http.StatusUnsupportedMediaType, "UNSUPPORTED_MEDIA_TYPE", "application/json is required", false, gin.H{}, ) return } var request struct { Username string `json:"username"` Password string `json:"password"` DeviceID string `json:"device_id"` DeviceToken string `json:"device_token"` AppVersion string `json:"app_version"` AndroidVersion string `json:"android_version"` } if err := decodeJSON(ctx, &request); err != nil { writePublicError( ctx, http.StatusBadRequest, "INVALID_JSON", "request body must be valid JSON", false, gin.H{}, ) return } attemptKey := authcommon.LoginAttemptKey( "buyer", ctx.Request.RemoteAddr, ) if allowed, wait := h.limiter.Allow(attemptKey); !allowed { ctx.Header( "Retry-After", strconv.Itoa(authcommon.RetryAfterSeconds(wait)), ) writePublicError( ctx, http.StatusTooManyRequests, "AUTH_RATE_LIMITED", "too many authentication attempts", true, gin.H{}, ) return } result, err := h.service.LoginBuyerDevice( ctx.Request.Context(), usecase.LoginBuyerDeviceCommand{ Username: request.Username, Password: request.Password, DeviceID: request.DeviceID, DeviceToken: request.DeviceToken, AppVersion: request.AppVersion, AndroidVersion: request.AndroidVersion, }, ) if err != nil { writeAuthError(ctx, err) return } h.limiter.Reset(attemptKey) ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "access_token": result.Token, "token_type": "Bearer", "expires_in": int(usecase.AccessTokenLifetime.Seconds()), "user": gin.H{ "id": result.User.ID, "username": result.User.Username, "role": result.User.Role, }, "device": gin.H{ "id": result.Device.ID, "enabled": result.Device.IsEnabled, }, }) } func requireAdminSession( authenticator AdminAuthenticator, ) gin.HandlerFunc { return func(ctx *gin.Context) { cookie, err := ctx.Request.Cookie( authcommon.AdminSessionCookieName, ) if err != nil || cookie.Value == "" { denyAdminSession(ctx) return } principal, err := authenticator.AuthenticateAdmin( ctx.Request.Context(), cookie.Value, ) if err != nil || principal.Role != domain.UserRoleAdmin || principal.DeviceID != "" { denyAdminSession(ctx) return } ctx.Request = ctx.Request.WithContext( authcommon.WithPrincipal( ctx.Request.Context(), principal, ), ) if isUnsafeAdminAPIRequest(ctx.Request) && !validAPIRequestCSRF(ctx.Request) { writePublicError( ctx, http.StatusForbidden, "CSRF_INVALID", "CSRF token is invalid", false, gin.H{}, ) ctx.Abort() return } ctx.Next() } } func RequireDeviceAccess( authenticator DeviceAuthenticator, ) gin.HandlerFunc { return func(ctx *gin.Context) { token, ok := bearerToken(ctx.GetHeader("Authorization")) if !ok { denyDeviceAccess(ctx) return } principal, err := authenticator.AuthenticateAccessToken( ctx.Request.Context(), token, ) if err != nil || principal.Role != domain.UserRoleBuyer || principal.DeviceID == "" { denyDeviceAccess(ctx) return } ctx.Request = ctx.Request.WithContext( authcommon.WithPrincipal( ctx.Request.Context(), principal, ), ) ctx.Next() } } func denyAdminSession(ctx *gin.Context) { ctx.Header("Cache-Control", "no-store") if strings.HasPrefix(ctx.Request.URL.Path, "/api/") { ctx.Abort() writePublicError( ctx, http.StatusUnauthorized, "ADMIN_SESSION_REQUIRED", "admin session required", false, gin.H{}, ) return } next := ctx.Request.URL.RequestURI() if next == "" || (next != "/tasks" && !strings.HasPrefix(next, "/tasks?") && !strings.HasPrefix(next, "/tasks/") && next != "/freight" && !strings.HasPrefix(next, "/freight?") && !strings.HasPrefix(next, "/freight/")) { next = "/tasks" } ctx.Abort() ctx.Redirect( http.StatusSeeOther, "/login?next="+url.QueryEscape(next), ) } func denyDeviceAccess(ctx *gin.Context) { ctx.Abort() writePublicError( ctx, http.StatusUnauthorized, "DEVICE_ACCESS_REQUIRED", "device access token required", false, gin.H{}, ) } func validAPIRequestCSRF(request *http.Request) bool { cookie, err := request.Cookie(authcommon.CSRFCookieName) if err != nil { return false } return authcommon.ValidCSRFPair( cookie.Value, request.Header.Get(authcommon.CSRFHeader), ) } func isUnsafeAdminAPIRequest(request *http.Request) bool { if !strings.HasPrefix(request.URL.Path, "/api/") { return false } switch request.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: return false default: return true } } func bearerToken(value string) (string, bool) { parts := strings.Fields(value) if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || !authcommon.ValidOpaqueValue(parts[1]) { return "", false } return parts[1], true } func writeAuthError(ctx *gin.Context, err error) { var typed *usecase.Error if !errors.As(err, &typed) { writePublicError( ctx, http.StatusInternalServerError, "INTERNAL_ERROR", "internal server error", false, gin.H{}, ) return } switch typed.Kind { case usecase.ErrorKindUnauthorized, usecase.ErrorKindInvalid: writePublicError( ctx, http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid credentials", false, gin.H{}, ) case usecase.ErrorKindForbidden: code := "AUTH_FORBIDDEN" message := "authentication is not allowed" if typed.Code == "AUTH_ACCOUNT_OR_DEVICE_DISABLED" { code = "AUTH_ACCOUNT_OR_DEVICE_DISABLED" message = "account or device is disabled" } writePublicError( ctx, http.StatusForbidden, code, message, false, gin.H{}, ) case usecase.ErrorKindConflict: writePublicError( ctx, http.StatusConflict, "AUTH_RESOURCE_CONFLICT", "authentication resource conflict", false, gin.H{}, ) case usecase.ErrorKindUnavailable: writePublicError( ctx, http.StatusServiceUnavailable, "AUTH_UNAVAILABLE", "authentication service is unavailable", true, gin.H{}, ) default: writePublicError( ctx, http.StatusInternalServerError, "INTERNAL_ERROR", "internal server error", false, gin.H{}, ) } }