feat(auth): implement user and device authentication
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
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 = "/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{},
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user