359 lines
7.6 KiB
Go
359 lines
7.6 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 != "/erp" && !strings.HasPrefix(next, "/erp?")) {
|
|
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{},
|
|
)
|
|
}
|
|
}
|