Files
cmroubao/backend-api/internal/transport/httpapi/auth_handlers.go
T

358 lines
7.5 KiB
Go

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{},
)
}
}