3609 lines
124 KiB
Go
3609 lines
124 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
|
|
"wucher/internal/domain/auth"
|
|
"wucher/internal/domain/contact"
|
|
"wucher/internal/service"
|
|
sharedconst "wucher/internal/shared/const"
|
|
"wucher/internal/shared/pkg/apperrorsx"
|
|
tzpkg "wucher/internal/shared/pkg/timezone"
|
|
"wucher/internal/shared/pkg/uuidv7"
|
|
"wucher/internal/transport/http/dto"
|
|
"wucher/internal/transport/http/jsonapi"
|
|
"wucher/internal/transport/http/response"
|
|
"wucher/internal/transport/http/validators"
|
|
)
|
|
|
|
type AuthHandler struct {
|
|
svc *service.AuthService
|
|
contactSvc contact.Service
|
|
storage fileManagerDownloadURLStorage
|
|
validate *validators.Validator
|
|
}
|
|
|
|
const (
|
|
pinVerificationHeader = "X-PIN-Verification-Token"
|
|
deviceTypeHeader = "X-Device-Type"
|
|
)
|
|
|
|
func NewAuthHandler(svc *service.AuthService) *AuthHandler {
|
|
return &AuthHandler{
|
|
svc: svc,
|
|
validate: validators.New(),
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) WithFileStorage(storage fileManagerDownloadURLStorage) *AuthHandler {
|
|
h.storage = storage
|
|
return h
|
|
}
|
|
|
|
func (h *AuthHandler) WithContactService(contactSvc contact.Service) *AuthHandler {
|
|
h.contactSvc = contactSvc
|
|
return h
|
|
}
|
|
|
|
// Register godoc
|
|
// @Summary Register user with email & password
|
|
// @Description Creates user (public role: from AUTH_DEFAULT_ROLE, fallback: user) and sends verification link to email without issuing an auth session.
|
|
// @Description Next: open verify link -> then login.
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.RegisterRequest true "JSON:API Register request"
|
|
// @Success 201 {object} dto.UserResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/register [post]
|
|
func (h *AuthHandler) Register(c *fiber.Ctx) error {
|
|
if h.svc.RegisterDisabled() {
|
|
return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
|
|
Status: "403",
|
|
Title: "Registration disabled",
|
|
Detail: "registration is disabled in this environment",
|
|
}})
|
|
}
|
|
|
|
var req dto.RegisterRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
if strings.TrimSpace(req.Data.Attributes.Username) == "" {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "username is required",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/username"},
|
|
}})
|
|
}
|
|
|
|
defaultRoleName := strings.TrimSpace(h.svc.DefaultRoleName())
|
|
if defaultRoleName == "" {
|
|
defaultRoleName = "user"
|
|
}
|
|
|
|
roleID, err := h.svc.GetRoleIDByName(c.UserContext(), defaultRoleName)
|
|
if err != nil {
|
|
return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Register.GetRoleIDByName", err)
|
|
}
|
|
|
|
user, err := h.svc.RegisterWithEmail(
|
|
c.UserContext(),
|
|
req.Data.Attributes.Email,
|
|
req.Data.Attributes.Username,
|
|
req.Data.Attributes.FirstName,
|
|
req.Data.Attributes.LastName,
|
|
strings.TrimSpace(req.Data.Attributes.MobilePhone),
|
|
req.Data.Attributes.Password,
|
|
roleID,
|
|
)
|
|
if err != nil {
|
|
return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Register", err)
|
|
}
|
|
|
|
id, _ := uuidv7.BytesToString(user.ID)
|
|
roleIDStr, _ := uuidv7.BytesToString(roleID)
|
|
resp := jsonapi.Resource{
|
|
Type: "user",
|
|
ID: id,
|
|
Attributes: map[string]any{
|
|
"email": user.Email,
|
|
"username": derefString(user.Username),
|
|
"first_name": user.FirstName,
|
|
"last_name": user.LastName,
|
|
"role_id": roleIDStr,
|
|
"role_ids": []string{roleIDStr},
|
|
"mobile_phone": user.MobilePhone,
|
|
"timezone": tzpkg.WithDefault(user.Timezone),
|
|
"registered": true,
|
|
},
|
|
}
|
|
return response.Write(c, fiber.StatusCreated, resp)
|
|
}
|
|
|
|
// Invite godoc
|
|
// @Summary Invite user
|
|
// @Description Creates user without password and sends set-password link to email.
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.InviteRequest true "JSON:API Invite request"
|
|
// @Success 201 {object} dto.UserResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/invite [post]
|
|
func (h *AuthHandler) Invite(c *fiber.Ctx) error {
|
|
var req dto.InviteRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
if strings.TrimSpace(req.Data.Attributes.Username) == "" {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "username is required",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/username"},
|
|
}})
|
|
}
|
|
|
|
roleID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.RoleID))
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "role_id is invalid UUID",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/role_id"},
|
|
}})
|
|
}
|
|
roleIDs, roleErrs := parseRoleIDs(req.Data.Attributes.RoleIDs, "/data/attributes/role_ids")
|
|
if len(roleErrs) > 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, roleErrs)
|
|
}
|
|
roleIDs = normalizeRoleIDs(roleID, roleIDs)
|
|
|
|
user, err := h.svc.InviteUserWithRoles(
|
|
c.UserContext(),
|
|
req.Data.Attributes.Email,
|
|
req.Data.Attributes.Username,
|
|
req.Data.Attributes.FirstName,
|
|
req.Data.Attributes.LastName,
|
|
strings.TrimSpace(req.Data.Attributes.MobilePhone),
|
|
roleID,
|
|
roleIDs,
|
|
)
|
|
if err != nil {
|
|
return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Invite", err)
|
|
}
|
|
|
|
id, _ := uuidv7.BytesToString(user.ID)
|
|
_, roles := authRoleResponseData(h.svc, c, user)
|
|
roleIDStr, roleIDList := roleResponseIDFields(roles)
|
|
resp := jsonapi.Resource{
|
|
Type: "user",
|
|
ID: id,
|
|
Attributes: map[string]any{
|
|
"email": user.Email,
|
|
"username": derefString(user.Username),
|
|
"first_name": user.FirstName,
|
|
"last_name": user.LastName,
|
|
"mobile_phone": user.MobilePhone,
|
|
"role_id": roleIDStr,
|
|
"role_ids": roleIDList,
|
|
"timezone": tzpkg.WithDefault(user.Timezone),
|
|
},
|
|
}
|
|
return response.Write(c, fiber.StatusCreated, resp)
|
|
}
|
|
|
|
// SetPassword godoc
|
|
// @Summary Set password from invite token
|
|
// @Description Sets first password for invited user and marks email as verified.
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.SetPasswordRequest true "JSON:API Set password request"
|
|
// @Success 200 {object} dto.WebAuthnResetSetupResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/set-password [post]
|
|
func (h *AuthHandler) SetPassword(c *fiber.Ctx) error {
|
|
var req dto.SetPasswordRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
if err := h.svc.SetPasswordWithInviteToken(c.UserContext(), req.Data.Attributes.Token, req.Data.Attributes.Password); err != nil {
|
|
return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.SetPassword", err)
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_set_password",
|
|
Attributes: map[string]any{
|
|
"success": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ResendSetPasswordInvite godoc
|
|
// @Summary Resend set-password invite
|
|
// @Description Triggers a new set-password invite email for an invited user by user_id. Response is generic for security.
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.ResendSetPasswordInviteRequest true "JSON:API resend set-password invite request"
|
|
// @Success 200 {object} dto.AuthSessionsResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/invite/resend-set-password [post]
|
|
func (h *AuthHandler) ResendSetPasswordInvite(c *fiber.Ctx) error {
|
|
var req dto.ResendSetPasswordInviteRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
userID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.UserID))
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "user_id is invalid UUID",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
|
|
}})
|
|
}
|
|
|
|
h.svc.ResendInviteSetPassword(c.UserContext(), userID)
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_resend_set_password",
|
|
Attributes: map[string]any{
|
|
"message": "If allowed, a set-password invite email has been sent.",
|
|
},
|
|
})
|
|
}
|
|
|
|
// ForgotPassword godoc
|
|
// @Summary Forgot password
|
|
// @Description Always returns a generic success response.
|
|
// @Tags Auth - Forgot Password
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.ForgotPasswordRequest true "JSON:API Forgot password request"
|
|
// @Success 200 {object} dto.AuthSessionsResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/forgot-password [post]
|
|
func (h *AuthHandler) ForgotPassword(c *fiber.Ctx) error {
|
|
var req dto.ForgotPasswordRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
h.svc.ForgotPassword(
|
|
c.UserContext(),
|
|
req.Data.Attributes.Email,
|
|
c.IP(),
|
|
c.Get(fiber.HeaderUserAgent),
|
|
)
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_forgot_password",
|
|
Attributes: map[string]any{
|
|
"message": "If the account exists, a password reset link has been sent.",
|
|
},
|
|
})
|
|
}
|
|
|
|
// ResetPassword godoc
|
|
// @Summary Reset password
|
|
// @Description Resets password using a single-use reset token.
|
|
// @Tags Auth - Forgot Password
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.ResetPasswordRequest true "JSON:API Reset password request"
|
|
// @Success 200 {object} dto.AuthSessionDeleteResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/reset-password [post]
|
|
func (h *AuthHandler) ResetPassword(c *fiber.Ctx) error {
|
|
var req dto.ResetPasswordRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
if req.Data.Attributes.NewPassword != req.Data.Attributes.ConfirmPassword {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "confirm_password must match new_password",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_password"},
|
|
}})
|
|
}
|
|
|
|
if err := h.svc.ResetPassword(c.UserContext(), req.Data.Attributes.Token, req.Data.Attributes.NewPassword); err != nil {
|
|
if errors.Is(err, service.ErrInvalidResetLink) || errors.Is(err, service.ErrInvalidToken) {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid reset link",
|
|
Detail: "This reset link is invalid or expired.",
|
|
}})
|
|
}
|
|
return writeAuthErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
|
|
Status: "500",
|
|
Title: "Reset password failed",
|
|
Detail: "unable to reset password at this time",
|
|
}})
|
|
}
|
|
|
|
clearAuthCookies(c, h.svc)
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_reset_password",
|
|
Attributes: map[string]any{
|
|
"message": "Password has been reset successfully. Please log in again.",
|
|
},
|
|
})
|
|
}
|
|
|
|
// SetupSecurityPIN godoc
|
|
// @Summary Setup security PIN
|
|
// @Description Creates a 6-digit security PIN for sensitive actions.
|
|
// @Tags Auth - PIN
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.SecurityPINSetupRequest true "JSON:API security PIN setup request"
|
|
// @Success 200 {object} dto.SecurityPINSetupResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/pin/setup [post]
|
|
func (h *AuthHandler) SetupSecurityPIN(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
|
|
e.Message = "missing auth context"
|
|
return apperrorsx.Write(c, e)
|
|
}
|
|
|
|
var req dto.SecurityPINSetupRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
if req.Data.Attributes.PIN != req.Data.Attributes.ConfirmPIN {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "confirm_pin must match pin",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_pin"},
|
|
}})
|
|
}
|
|
|
|
if err := h.svc.SetSecurityPIN(c.UserContext(), userID, req.Data.Attributes.PIN); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrSecurityPINAlreadySet):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotSet))
|
|
case errors.Is(err, service.ErrInvalidSecurityPIN):
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "pin must be exactly 6 digits",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/pin"},
|
|
}})
|
|
default:
|
|
return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.SetupSecurityPIN", err)
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_pin_setup",
|
|
Attributes: map[string]any{
|
|
"configured": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
func derefString(v *string) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(*v)
|
|
}
|
|
|
|
// VerifySecurityPIN godoc
|
|
// @Summary Verify security PIN for sensitive action
|
|
// @Description Verifies PIN and returns a short-lived one-time action token.
|
|
// @Tags Auth - PIN
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.SecurityPINVerifyRequest true "JSON:API security PIN verify request"
|
|
// @Success 200 {object} dto.SecurityPINVerifyResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Failure 429 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/pin/verify [post]
|
|
func (h *AuthHandler) VerifySecurityPIN(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
|
|
e.Message = "missing auth context"
|
|
return apperrorsx.Write(c, e)
|
|
}
|
|
|
|
var req dto.SecurityPINVerifyRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
sessionID := ""
|
|
if accessToken := readValidAccessToken(c, h.svc); accessToken != "" {
|
|
sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
|
|
if err != nil {
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid))
|
|
}
|
|
sessionID = sid
|
|
}
|
|
|
|
token, err := h.svc.VerifySecurityPINForActionInSession(c.UserContext(), userID, req.Data.Attributes.PIN, req.Data.Attributes.Action, sessionID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrSecurityPINNotSet):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotSet))
|
|
case errors.Is(err, service.ErrSecurityPINLocked):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
|
|
case errors.Is(err, service.ErrSecurityPINAttemptLimit):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
|
|
case errors.Is(err, service.ErrSecurityPINResetRequired):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
|
|
case errors.Is(err, service.ErrInvalidSecurityPIN):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINIncorrect))
|
|
default:
|
|
return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.VerifySecurityPIN", err)
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_pin_verify",
|
|
Attributes: map[string]any{
|
|
"verified": true,
|
|
"action_token": token,
|
|
"action_token_ttl_ms": h.svc.SecurityPINTokenTTL().Milliseconds(),
|
|
},
|
|
})
|
|
}
|
|
|
|
// ChangeSecurityPIN godoc
|
|
// @Summary Change security PIN
|
|
// @Tags Auth - PIN
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.SecurityPINChangeRequest true "JSON:API security PIN change request"
|
|
// @Success 200 {object} dto.AuthSessionsResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 429 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/pin/change [post]
|
|
func (h *AuthHandler) ChangeSecurityPIN(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
|
|
e.Message = "missing auth context"
|
|
return apperrorsx.Write(c, e)
|
|
}
|
|
|
|
var req dto.SecurityPINChangeRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
if req.Data.Attributes.NewPIN != req.Data.Attributes.ConfirmPIN {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "confirm_pin must match new_pin",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_pin"},
|
|
}})
|
|
}
|
|
|
|
if err := h.svc.ChangeSecurityPIN(c.UserContext(), userID, req.Data.Attributes.CurrentPIN, req.Data.Attributes.NewPIN); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrSecurityPINNotSet):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotSet))
|
|
case errors.Is(err, service.ErrSecurityPINLocked):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
|
|
case errors.Is(err, service.ErrSecurityPINAttemptLimit):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
|
|
case errors.Is(err, service.ErrSecurityPINResetRequired):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
|
|
case errors.Is(err, service.ErrInvalidSecurityPIN):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINIncorrect))
|
|
default:
|
|
return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.ChangeSecurityPIN", err)
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_pin_change",
|
|
Attributes: map[string]any{
|
|
"updated": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ForgotSecurityPIN godoc
|
|
// @Summary Forgot security PIN
|
|
// @Description Always returns a generic success response.
|
|
// @Tags Auth - PIN
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.ForgotSecurityPINRequest true "JSON:API forgot security PIN request"
|
|
// @Success 200 {object} dto.AuthSessionDeleteResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/pin/forgot [post]
|
|
func (h *AuthHandler) ForgotSecurityPIN(c *fiber.Ctx) error {
|
|
var req dto.ForgotSecurityPINRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
h.svc.ForgotSecurityPIN(
|
|
c.UserContext(),
|
|
req.Data.Attributes.Email,
|
|
c.IP(),
|
|
c.Get(fiber.HeaderUserAgent),
|
|
)
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_forgot_pin",
|
|
Attributes: map[string]any{
|
|
"message": "If the account exists, a security PIN reset link has been sent.",
|
|
},
|
|
})
|
|
}
|
|
|
|
// RequestSecurityPINReset godoc
|
|
// @Summary Request security PIN reset email for blocked PIN
|
|
// @Description Verifies current account password, then sends PIN reset email to currently logged-in user when PIN is blocked.
|
|
// @Tags Auth - PIN
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.RequestSecurityPINRequest true "JSON:API request security PIN reset payload"
|
|
// @Success 200 {object} dto.RequestSecurityPINResetResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Failure 429 {object} dto.ErrorResponse
|
|
// @Failure 500 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/pin/request-reset [post]
|
|
func (h *AuthHandler) RequestSecurityPINReset(c *fiber.Ctx) error {
|
|
var req dto.RequestSecurityPINRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
|
|
e.Message = "missing auth context"
|
|
return apperrorsx.Write(c, e)
|
|
}
|
|
|
|
method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method))
|
|
if method == "" {
|
|
method = "password"
|
|
}
|
|
switch method {
|
|
case "password":
|
|
if strings.TrimSpace(req.Data.Attributes.Password) == "" {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "password is required when method is password",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"},
|
|
}})
|
|
}
|
|
case "microsoft":
|
|
if strings.TrimSpace(req.Data.Attributes.ReauthToken) == "" {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "reauth_token is required when method is microsoft",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/reauth_token"},
|
|
}})
|
|
}
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "method must be one of: password, microsoft",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
|
|
}})
|
|
}
|
|
|
|
if err := h.svc.RequestSecurityPINReset(c.UserContext(), userID, method, req.Data.Attributes.Password, req.Data.Attributes.ReauthToken, c.IP(), c.Get(fiber.HeaderUserAgent)); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrInvalidPINResetPassword):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrUserUnauthorized))
|
|
case errors.Is(err, service.ErrInvalidSSOReauth):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid))
|
|
case errors.Is(err, service.ErrInvalidReauthMethod):
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "method must be one of: password, microsoft",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
|
|
}})
|
|
case errors.Is(err, service.ErrSecurityPINNotBlocked):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotBlocked))
|
|
case errors.Is(err, service.ErrSecurityPINResetRatelimit):
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINAttemptLimitReached))
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
|
|
Status: "500",
|
|
Title: "Reset request failed",
|
|
Detail: "unable to send reset email at this time",
|
|
}})
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_pin_request_reset",
|
|
Attributes: map[string]any{
|
|
"message": "If allowed, a security PIN reset link has been sent to your email.",
|
|
},
|
|
})
|
|
}
|
|
|
|
// ResetSecurityPIN godoc
|
|
// @Summary Reset security PIN
|
|
// @Description Resets security PIN using a re-auth method. For method=password, send the single-use reset token from the email link plus password. For method=microsoft, send only reauth_token from Microsoft re-auth.
|
|
// @Tags Auth - PIN
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.ResetSecurityPINRequest true "JSON:API reset security PIN request"
|
|
// @Success 200 {object} dto.AuthSessionsResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/pin/reset [post]
|
|
func (h *AuthHandler) ResetSecurityPIN(c *fiber.Ctx) error {
|
|
var req dto.ResetSecurityPINRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
if req.Data.Attributes.NewPIN != req.Data.Attributes.ConfirmPIN {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "confirm_pin must match new_pin",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_pin"},
|
|
}})
|
|
}
|
|
|
|
method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method))
|
|
if method == "" {
|
|
method = "password"
|
|
}
|
|
switch method {
|
|
case "password":
|
|
if strings.TrimSpace(req.Data.Attributes.Token) == "" {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "token is required when method is password",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/token"},
|
|
}})
|
|
}
|
|
if strings.TrimSpace(req.Data.Attributes.Password) == "" {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "password is required when method is password",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"},
|
|
}})
|
|
}
|
|
case "microsoft":
|
|
if strings.TrimSpace(req.Data.Attributes.ReauthToken) == "" {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "reauth_token is required when method is microsoft",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/reauth_token"},
|
|
}})
|
|
}
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "method must be one of: password, microsoft",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
|
|
}})
|
|
}
|
|
|
|
if err := h.svc.ResetSecurityPIN(c.UserContext(), req.Data.Attributes.Token, method, req.Data.Attributes.Password, req.Data.Attributes.ReauthToken, req.Data.Attributes.NewPIN); err != nil {
|
|
if errors.Is(err, service.ErrInvalidResetLink) {
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrInvalidRequest))
|
|
}
|
|
if errors.Is(err, service.ErrInvalidPINResetPassword) {
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrUserUnauthorized))
|
|
}
|
|
if errors.Is(err, service.ErrInvalidSSOReauth) {
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid))
|
|
}
|
|
if errors.Is(err, service.ErrInvalidReauthMethod) {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "method must be one of: password, microsoft",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
|
|
}})
|
|
}
|
|
if errors.Is(err, service.ErrInvalidSecurityPIN) {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "new_pin must be exactly 6 digits",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/new_pin"},
|
|
}})
|
|
}
|
|
return writeAuthErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
|
|
Status: "500",
|
|
Title: "Reset security PIN failed",
|
|
Detail: "unable to reset security PIN at this time",
|
|
}})
|
|
}
|
|
|
|
attrs := map[string]any{
|
|
"message": "Security PIN has been reset successfully.",
|
|
"reset_success": true,
|
|
}
|
|
if redirectURL := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirectURL != "" {
|
|
attrs["redirect_url"] = redirectURL
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_reset_pin",
|
|
Attributes: attrs,
|
|
})
|
|
}
|
|
|
|
// Login godoc
|
|
// @Summary Login with email & password
|
|
// @Description If TOTP enabled, returns 202 + challenge_token (use /auth/totp/verify).
|
|
// @Description If TOTP is not enabled, returns 202 + email OTP challenge_token (use /auth/email-otp/send then /auth/email-otp/verify).
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.LoginRequest true "JSON:API Login request"
|
|
// @Success 200 {object} dto.AuthCurrentUserResponse
|
|
// @Success 202 {object} dto.LoginTOTPRequiredResponse
|
|
// @Success 202 {object} dto.LoginEmailOTPRequiredResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/login [post]
|
|
func (h *AuthHandler) Login(c *fiber.Ctx) error {
|
|
var req dto.LoginRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
user, err := h.svc.LoginWithEmailPassword(c.UserContext(), req.Data.Attributes.Email, req.Data.Attributes.Password)
|
|
if err != nil {
|
|
return apperrorsx.Write(c, apperrorsx.Translate(apperrorsx.ModuleAuth, err))
|
|
}
|
|
|
|
totpEnabled, _ := h.svc.IsTOTPEnabled(c.UserContext(), user.ID)
|
|
webauthnConfigured, _ := h.svc.IsWebAuthnConfigured(c.UserContext(), user.ID)
|
|
linkedSSO, _ := h.svc.IsSSOLinked(c.UserContext(), user.ID)
|
|
pinConfigured := len(user.SecurityPINHash) > 0
|
|
if totpEnabled {
|
|
token, err := h.svc.CreateTOTPChallenge(c.UserContext(), user.ID)
|
|
if err != nil {
|
|
return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Login.CreateTOTPChallenge", err)
|
|
}
|
|
|
|
attrs := map[string]any{
|
|
"requires_totp": true,
|
|
"challenge_token": token,
|
|
"totp_enabled": true,
|
|
"email_otp_enabled": false,
|
|
"webauthn_configured": webauthnConfigured,
|
|
"linked_sso": linkedSSO,
|
|
"pin_configured": pinConfigured,
|
|
}
|
|
|
|
// When email OTP is enabled globally, mint a parallel email-OTP challenge
|
|
// so the user can fall back to email verification without re-submitting credentials.
|
|
if h.svc.IsLoginEmailOTPEnabled(c.UserContext()) {
|
|
emailToken, emailErr := h.svc.CreateEmailOTPChallenge(c.UserContext(), user.ID)
|
|
if emailErr == nil {
|
|
attrs["email_otp_enabled"] = true
|
|
attrs["email_otp_challenge_token"] = emailToken
|
|
attrs["email_otp_resend_cooldown_seconds"] = int(h.svc.EmailOTPResendCooldown().Seconds())
|
|
attrs["email_otp_max_resend_per_hour"] = h.svc.EmailOTPMaxResendPerHour()
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusAccepted, jsonapi.Resource{
|
|
Type: "auth_totp_required",
|
|
Attributes: attrs,
|
|
})
|
|
}
|
|
|
|
if !h.svc.IsLoginEmailOTPEnabled(c.UserContext()) {
|
|
tokens, err := h.svc.IssueTokensWithClientAndDeviceType(
|
|
c.UserContext(),
|
|
user,
|
|
c.Get(fiber.HeaderUserAgent),
|
|
c.IP(),
|
|
readDeviceTypeHint(c),
|
|
)
|
|
if err != nil {
|
|
return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Login.IssueTokens", err)
|
|
}
|
|
setAuthCookies(c, h.svc, tokens)
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_tokens",
|
|
Attributes: map[string]any{
|
|
"access_expires_in": int64(h.svc.AccessTTL().Seconds()),
|
|
"refresh_expires_in": int64(tokens.RefreshTTL.Seconds()),
|
|
"requires_totp_setup": true,
|
|
"email_otp_enabled": false,
|
|
},
|
|
})
|
|
}
|
|
|
|
emailChallengeToken, err := h.svc.CreateEmailOTPChallenge(c.UserContext(), user.ID)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Email OTP challenge failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
return response.Write(c, fiber.StatusAccepted, jsonapi.Resource{
|
|
Type: "auth_email_otp_required",
|
|
Attributes: map[string]any{
|
|
"requires_email_otp": true,
|
|
"challenge_token": emailChallengeToken,
|
|
"resend_cooldown_seconds": int(h.svc.EmailOTPResendCooldown().Seconds()),
|
|
"max_resend_per_hour": h.svc.EmailOTPMaxResendPerHour(),
|
|
"requires_totp_setup": true,
|
|
"webauthn_configured": webauthnConfigured,
|
|
"linked_sso": linkedSSO,
|
|
"pin_configured": pinConfigured,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Exchange godoc
|
|
// @Summary Exchange frontend Microsoft SSO token into backend session
|
|
// @Description Accepts Microsoft token payload from frontend ssoSilent and issues backend auth cookies.
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.ExchangeRequest true "JSON:API Exchange request"
|
|
// @Success 200 {object} dto.TokenResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/exchange [post]
|
|
func (h *AuthHandler) Exchange(c *fiber.Ctx) error {
|
|
var req dto.ExchangeRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
identity, err := h.svc.ExchangeMicrosoftToken(
|
|
c.UserContext(),
|
|
req.Data.Attributes.AccessToken,
|
|
req.Data.Attributes.IDToken,
|
|
req.Data.Attributes.IDTokenClaims.ToMap(),
|
|
)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
|
|
user, err := h.svc.GetUserByID(c.UserContext(), identity.UserID)
|
|
if err != nil || user == nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "linked user is not found",
|
|
}})
|
|
}
|
|
|
|
// Issue internal session tokens. Keep auth provider as local since FE-managed ssoSilent
|
|
// does not provide backend-managed Microsoft refresh token.
|
|
tokens, err := h.svc.IssueTokensWithClientAndDeviceType(
|
|
c.UserContext(),
|
|
user,
|
|
c.Get(fiber.HeaderUserAgent),
|
|
c.IP(),
|
|
readDeviceTypeHint(c),
|
|
)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Token issue failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
setAuthCookies(c, h.svc, tokens)
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_tokens",
|
|
Attributes: map[string]any{
|
|
"access_expires_in": int64(h.svc.AccessTTL().Seconds()),
|
|
"refresh_expires_in": int64(tokens.RefreshTTL.Seconds()),
|
|
},
|
|
})
|
|
}
|
|
|
|
// VerifyEmail godoc
|
|
// @Summary Verify email
|
|
// @Description Confirms email verification after register.
|
|
// @Tags Auth
|
|
// @Produce json
|
|
// @Param token query string true "Verification token"
|
|
// @Success 200 {object} dto.AuthSessionDeleteResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/verify-email [get]
|
|
func (h *AuthHandler) VerifyEmail(c *fiber.Ctx) error {
|
|
token := c.Query("token")
|
|
if token == "" {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid token",
|
|
Detail: "token is required",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/query/token"},
|
|
}})
|
|
}
|
|
if err := h.svc.VerifyEmail(c.UserContext(), token); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid token",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_verify_email",
|
|
Attributes: map[string]any{
|
|
"verified": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// MicrosoftLogin godoc
|
|
// @Summary Microsoft Entra SSO login
|
|
// @Description Returns JSON:API payload containing Microsoft authorization URL.
|
|
// @Description Backend starts with silent session-check (`prompt=none`). If Microsoft session is inactive, callback automatically falls back to interactive login.
|
|
// @Tags Auth
|
|
// @Produce json
|
|
// @Success 200 {object} dto.MicrosoftLoginResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/microsoft/login [get]
|
|
func (h *AuthHandler) MicrosoftLogin(c *fiber.Ctx) error {
|
|
// Interactive sign-in for an explicit "Login with Microsoft" action. Silent session
|
|
// checks (prompt=none) are served separately by /microsoft/login-check.
|
|
urlStr, err := h.svc.BeginMicrosoftLogin(c.UserContext())
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "SSO not configured",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_microsoft_url",
|
|
Attributes: map[string]any{
|
|
"url": urlStr,
|
|
"redirect_url": urlStr,
|
|
},
|
|
})
|
|
}
|
|
|
|
// MicrosoftLoginCheck godoc
|
|
// @Summary Microsoft Entra silent session check URL
|
|
// @Description Returns JSON:API payload containing Microsoft authorization URL with prompt=none for strict session check.
|
|
// @Tags Auth
|
|
// @Produce json
|
|
// @Success 200 {object} dto.MicrosoftLoginResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/microsoft/login-check [get]
|
|
func (h *AuthHandler) MicrosoftLoginCheck(c *fiber.Ctx) error {
|
|
urlStr, err := h.svc.BeginMicrosoftSessionCheckStrict(c.UserContext())
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "SSO not configured",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_microsoft_url",
|
|
Attributes: map[string]any{
|
|
"url": urlStr,
|
|
"redirect_url": urlStr,
|
|
},
|
|
})
|
|
}
|
|
|
|
// MicrosoftLink godoc
|
|
// @Summary Microsoft Entra SSO link URL
|
|
// @Description Returns JSON:API payload containing Microsoft authorization URL for linking SSO to the current authenticated user.
|
|
// @Tags Auth
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Success 200 {object} dto.MicrosoftLoginResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/microsoft/link [get]
|
|
func (h *AuthHandler) MicrosoftLink(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
urlStr, err := h.svc.BeginMicrosoftLink(c.UserContext(), userID)
|
|
if err != nil {
|
|
status := fiber.StatusBadRequest
|
|
title := "SSO link failed"
|
|
detail := "an internal error occurred"
|
|
if errors.Is(err, service.ErrUserNotFound) {
|
|
status = fiber.StatusNotFound
|
|
title = "User not found"
|
|
detail = "user not found"
|
|
}
|
|
return writeAuthErrors(c, status, []jsonapi.ErrorObject{{
|
|
Status: strconv.Itoa(status),
|
|
Title: title,
|
|
Detail: detail,
|
|
}})
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_microsoft_url",
|
|
Attributes: map[string]any{
|
|
"url": urlStr,
|
|
"redirect_url": urlStr,
|
|
},
|
|
})
|
|
}
|
|
|
|
// MicrosoftReauth godoc
|
|
// @Summary Microsoft Entra SSO reauth URL
|
|
// @Description Starts a Microsoft reauthentication flow for supported sensitive actions.
|
|
// @Tags Auth
|
|
// @Produce json
|
|
// @Param purpose query string true "Supported values: pin_reset_request, pin_reset_confirm"
|
|
// @Param token query string false "Required when purpose=pin_reset_confirm"
|
|
// @Success 200 {object} dto.MicrosoftLoginResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/microsoft/reauth [get]
|
|
func (h *AuthHandler) MicrosoftReauth(c *fiber.Ctx) error {
|
|
purpose := strings.TrimSpace(c.Query("purpose"))
|
|
var (
|
|
urlStr string
|
|
err error
|
|
)
|
|
|
|
switch purpose {
|
|
case "pin_reset_request":
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
urlStr, err = h.svc.BeginMicrosoftReauth(c.UserContext(), userID, purpose)
|
|
case "pin_reset_confirm":
|
|
urlStr, err = h.svc.BeginMicrosoftPINResetReauth(c.UserContext(), c.Query("token"))
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid reauth request",
|
|
Detail: "purpose must be one of: pin_reset_request, pin_reset_confirm",
|
|
}})
|
|
}
|
|
if err != nil {
|
|
status := fiber.StatusBadRequest
|
|
title := "SSO reauth failed"
|
|
detail := "an internal error occurred"
|
|
if errors.Is(err, service.ErrInvalidResetLink) {
|
|
title = "Invalid reset link"
|
|
detail = "This reset link is invalid or expired."
|
|
}
|
|
return writeAuthErrors(c, status, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: title,
|
|
Detail: detail,
|
|
}})
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_microsoft_url",
|
|
Attributes: map[string]any{
|
|
"url": urlStr,
|
|
"redirect_url": urlStr,
|
|
"purpose": purpose,
|
|
},
|
|
})
|
|
}
|
|
|
|
// MicrosoftCallback godoc
|
|
// @Summary Microsoft Entra SSO callback
|
|
// @Description Completes Microsoft SSO login. Login succeeds only when SSO identity is already linked to an existing user.
|
|
// @Tags Auth
|
|
// @Produce json
|
|
// @Param code query string true "Authorization code"
|
|
// @Param state query string true "State"
|
|
// @Success 200 {object} dto.MicrosoftCallbackResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/microsoft/callback [get]
|
|
func (h *AuthHandler) MicrosoftCallback(c *fiber.Ctx) error {
|
|
requestID := strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID))
|
|
if requestID == "" {
|
|
requestID = strings.TrimSpace(c.Get(fiber.HeaderXRequestID))
|
|
}
|
|
if requestID == "" {
|
|
requestID = strings.TrimSpace(c.Get("X-Request-Id"))
|
|
}
|
|
if requestID == "" {
|
|
requestID = strings.TrimSpace(c.Get("X-Request-ID"))
|
|
}
|
|
if requestID == "" {
|
|
requestID = strings.TrimSpace(c.Get("x-request-id"))
|
|
}
|
|
if requestID == "" {
|
|
requestID = strings.TrimSpace(c.Get("x-requestid"))
|
|
}
|
|
if requestID == "" {
|
|
if v := c.Locals("requestid"); v != nil {
|
|
requestID = strings.TrimSpace(fmt.Sprint(v))
|
|
}
|
|
}
|
|
|
|
logCallbackFailure := func(status int, reason, title, detail string, cause error) {
|
|
args := []any{
|
|
slog.String("request_id", requestID),
|
|
slog.Int("status", status),
|
|
slog.String("reason", strings.TrimSpace(reason)),
|
|
slog.String("title", strings.TrimSpace(title)),
|
|
slog.String("detail", strings.TrimSpace(detail)),
|
|
slog.String("path", c.Path()),
|
|
slog.String("mode", strings.TrimSpace(c.Query("mode"))),
|
|
slog.String("state", strings.TrimSpace(c.Query("state"))),
|
|
slog.String("microsoft_error", strings.TrimSpace(c.Query("error"))),
|
|
slog.Bool("has_code", strings.TrimSpace(c.Query("code")) != ""),
|
|
}
|
|
if cause != nil {
|
|
args = append(args, slog.Any("cause", cause))
|
|
}
|
|
slog.WarnContext(c.UserContext(), "microsoft callback failed", args...)
|
|
}
|
|
|
|
writeCallbackError := func(status int, title, detail, reason string, source *jsonapi.ErrorSource) error {
|
|
logCallbackFailure(status, reason, title, detail, nil)
|
|
if redirect := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirect != "" && shouldUseBrowserRedirect(c) {
|
|
if redirectURL, parseErr := url.Parse(redirect); parseErr == nil {
|
|
q := redirectURL.Query()
|
|
q.Set("sso", "error")
|
|
q.Set("status", strconv.Itoa(status))
|
|
if strings.TrimSpace(reason) != "" {
|
|
q.Set("reason", strings.TrimSpace(reason))
|
|
}
|
|
if strings.TrimSpace(title) != "" {
|
|
q.Set("title", strings.TrimSpace(title))
|
|
}
|
|
redirectURL.RawQuery = q.Encode()
|
|
return c.Redirect(redirectURL.String(), fiber.StatusFound)
|
|
}
|
|
}
|
|
errObj := jsonapi.ErrorObject{
|
|
Status: strconv.Itoa(status),
|
|
Title: title,
|
|
Detail: detail,
|
|
Source: source,
|
|
}
|
|
return writeAuthErrors(c, status, []jsonapi.ErrorObject{errObj})
|
|
}
|
|
|
|
code := strings.TrimSpace(c.Query("code"))
|
|
state := strings.TrimSpace(c.Query("state"))
|
|
if state == "" {
|
|
return writeCallbackError(fiber.StatusBadRequest, "Invalid callback", "state is required", "invalid_callback", nil)
|
|
}
|
|
mode, err := h.svc.MicrosoftStateMode(c.UserContext(), state)
|
|
if err != nil {
|
|
logCallbackFailure(
|
|
fiber.StatusBadRequest,
|
|
"invalid_callback_state",
|
|
"Invalid callback state",
|
|
err.Error(),
|
|
err,
|
|
)
|
|
return writeCallbackError(
|
|
fiber.StatusBadRequest,
|
|
"Invalid callback state",
|
|
"an internal error occurred",
|
|
"invalid_callback_state",
|
|
&jsonapi.ErrorSource{Pointer: "/query/state"},
|
|
)
|
|
}
|
|
if code == "" {
|
|
ssoErr := strings.TrimSpace(c.Query("error"))
|
|
if mode == "login_check" {
|
|
h.svc.ClearMicrosoftState(c.UserContext(), state)
|
|
if ssoErr != "" {
|
|
urlStr, loginErr := h.svc.BeginMicrosoftLogin(c.UserContext())
|
|
if loginErr != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Microsoft session not active",
|
|
Detail: "microsoft sso session is not active: " + ssoErr,
|
|
}})
|
|
}
|
|
if shouldUseBrowserRedirect(c) {
|
|
return c.Redirect(urlStr, fiber.StatusFound)
|
|
}
|
|
return response.Write(c, fiber.StatusUnauthorized, jsonapi.Resource{
|
|
Type: "auth_microsoft_url",
|
|
Attributes: map[string]any{
|
|
"url": urlStr,
|
|
"redirect_url": urlStr,
|
|
"interaction_required": true,
|
|
"microsoft_error": ssoErr,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
if mode == "login_check_strict" && isMicrosoftInteractionRequiredError(ssoErr) {
|
|
h.svc.ClearMicrosoftState(c.UserContext(), state)
|
|
urlStr, loginErr := h.svc.BeginMicrosoftLogin(c.UserContext())
|
|
if loginErr != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Microsoft session not active",
|
|
Detail: "microsoft sso session is not active: " + ssoErr,
|
|
}})
|
|
}
|
|
if shouldUseBrowserRedirect(c) {
|
|
return c.Redirect(urlStr, fiber.StatusFound)
|
|
}
|
|
return response.Write(c, fiber.StatusUnauthorized, jsonapi.Resource{
|
|
Type: "auth_microsoft_url",
|
|
Attributes: map[string]any{
|
|
"url": urlStr,
|
|
"redirect_url": urlStr,
|
|
"interaction_required": true,
|
|
"microsoft_error": ssoErr,
|
|
},
|
|
})
|
|
}
|
|
if mode == "login_check_strict" {
|
|
h.svc.ClearMicrosoftState(c.UserContext(), state)
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Microsoft session not active",
|
|
Detail: "microsoft sso session is not active",
|
|
}})
|
|
}
|
|
return writeCallbackError(fiber.StatusBadRequest, "Invalid callback", "code is required", "missing_code", nil)
|
|
}
|
|
|
|
if mode == "reauth" {
|
|
reauthToken, purpose, err := h.svc.CompleteMicrosoftReauth(c.UserContext(), code, state)
|
|
if err != nil {
|
|
status := fiber.StatusBadRequest
|
|
title := "SSO reauth failed"
|
|
if errors.Is(err, service.ErrInvalidSSOState) {
|
|
title = "Invalid callback state"
|
|
}
|
|
if errors.Is(err, service.ErrInvalidSSOReauth) {
|
|
status = fiber.StatusUnauthorized
|
|
}
|
|
reason := "sso_reauth_failed"
|
|
if status == fiber.StatusUnauthorized {
|
|
reason = "invalid_sso_reauth"
|
|
}
|
|
logCallbackFailure(status, reason, title, err.Error(), err)
|
|
return writeCallbackError(status, title, "an internal error occurred", reason, nil)
|
|
}
|
|
if redirect := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirect != "" && shouldUseBrowserRedirect(c) {
|
|
redirectURL, parseErr := url.Parse(redirect)
|
|
if parseErr == nil {
|
|
q := redirectURL.Query()
|
|
q.Set("sso_reauth", "1")
|
|
q.Set("purpose", purpose)
|
|
q.Set("reauth_token", reauthToken)
|
|
redirectURL.RawQuery = q.Encode()
|
|
return c.Redirect(redirectURL.String(), fiber.StatusFound)
|
|
}
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_microsoft_reauth",
|
|
Attributes: map[string]any{
|
|
"purpose": purpose,
|
|
"reauth_token": reauthToken,
|
|
},
|
|
})
|
|
}
|
|
|
|
if mode == "link" {
|
|
identity, err := h.svc.CompleteMicrosoftLink(c.UserContext(), code, state)
|
|
if err != nil {
|
|
logCallbackFailure(fiber.StatusBadRequest, "sso_link_failed", "SSO link failed", err.Error(), err)
|
|
switch {
|
|
case errors.Is(err, service.ErrInvalidSSOState):
|
|
return writeCallbackError(
|
|
fiber.StatusBadRequest,
|
|
"Invalid callback state",
|
|
"an internal error occurred",
|
|
"invalid_callback_state",
|
|
&jsonapi.ErrorSource{Pointer: "/query/state"},
|
|
)
|
|
case errors.Is(err, service.ErrUserNotFound):
|
|
return writeCallbackError(fiber.StatusNotFound, "User not found", "user not found", "user_not_found", nil)
|
|
case errors.Is(err, service.ErrSSOAlreadyLinked):
|
|
return writeCallbackError(
|
|
fiber.StatusConflict,
|
|
"SSO already linked",
|
|
"user already has an sso mapping",
|
|
"sso_already_linked",
|
|
nil,
|
|
)
|
|
case errors.Is(err, service.ErrSSOLinkedToAnotherUser):
|
|
return writeCallbackError(
|
|
fiber.StatusConflict,
|
|
"SSO linked to another user",
|
|
"this sso account is already linked to another user",
|
|
"sso_linked_to_another_user",
|
|
nil,
|
|
)
|
|
default:
|
|
return writeCallbackError(fiber.StatusBadRequest, "SSO link failed", "an internal error occurred", "sso_link_failed", nil)
|
|
}
|
|
}
|
|
if redirect := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirect != "" && shouldUseBrowserRedirect(c) {
|
|
redirectURL, parseErr := url.Parse(redirect)
|
|
if parseErr == nil {
|
|
q := redirectURL.Query()
|
|
q.Set("sso_link", "1")
|
|
q.Set("provider", identity.Provider)
|
|
redirectURL.RawQuery = q.Encode()
|
|
return c.Redirect(redirectURL.String(), fiber.StatusFound)
|
|
}
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_sso_link",
|
|
Attributes: map[string]any{
|
|
"provider": identity.Provider,
|
|
"provider_subject": identity.ProviderSubject,
|
|
"email": identity.Email,
|
|
},
|
|
})
|
|
}
|
|
|
|
if mode == "login_check_strict" {
|
|
if _, _, err := h.svc.CompleteMicrosoftLogin(c.UserContext(), code, state); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Microsoft session not active",
|
|
Detail: "microsoft sso session is not active",
|
|
}})
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_microsoft_session",
|
|
Attributes: map[string]any{
|
|
"active": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
identity, microsoftSessionID, err := h.svc.CompleteMicrosoftLogin(c.UserContext(), code, state)
|
|
if err != nil {
|
|
logCallbackFailure(fiber.StatusBadRequest, "sso_login_failed", "SSO login failed", err.Error(), err)
|
|
switch {
|
|
case errors.Is(err, service.ErrInvalidSSOState):
|
|
return writeCallbackError(
|
|
fiber.StatusBadRequest,
|
|
"Invalid callback state",
|
|
"an internal error occurred",
|
|
"invalid_callback_state",
|
|
&jsonapi.ErrorSource{Pointer: "/query/state"},
|
|
)
|
|
case errors.Is(err, service.ErrSSONotLinked):
|
|
return writeCallbackError(
|
|
fiber.StatusUnauthorized,
|
|
"SSO not linked",
|
|
"sso identity is not linked to any user",
|
|
"sso_not_linked",
|
|
nil,
|
|
)
|
|
case errors.Is(err, service.ErrSSOEmailNotFound):
|
|
return writeCallbackError(
|
|
fiber.StatusUnauthorized,
|
|
"SSO email not found",
|
|
"sso account email is not registered",
|
|
"sso_email_not_found",
|
|
nil,
|
|
)
|
|
case errors.Is(err, service.ErrUserNotFound):
|
|
return writeCallbackError(fiber.StatusNotFound, "User not found", "linked user is not found", "user_not_found", nil)
|
|
default:
|
|
return writeCallbackError(fiber.StatusBadRequest, "SSO login failed", "an internal error occurred", "sso_login_failed", nil)
|
|
}
|
|
}
|
|
|
|
// Issue cookies and redirect to FE if configured
|
|
user, err := h.svc.GetUserByID(c.UserContext(), identity.UserID)
|
|
if err == nil && user != nil {
|
|
tokens, err := h.svc.IssueTokensWithClientForProviderAndMicrosoftSID(
|
|
c.UserContext(),
|
|
user,
|
|
c.Get(fiber.HeaderUserAgent),
|
|
c.IP(),
|
|
"microsoft",
|
|
microsoftSessionID,
|
|
)
|
|
if err == nil {
|
|
setAuthCookiesForProvider(c, h.svc, tokens, "microsoft")
|
|
}
|
|
}
|
|
|
|
if redirect := h.svc.SSOSuccessRedirectURL(); redirect != "" {
|
|
if shouldUseBrowserRedirect(c) {
|
|
return c.Redirect(redirect, fiber.StatusFound)
|
|
}
|
|
}
|
|
|
|
id, _ := uuidv7.BytesToString(identity.ID)
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_identity",
|
|
ID: id,
|
|
Attributes: map[string]any{
|
|
"provider": identity.Provider,
|
|
"provider_subject": identity.ProviderSubject,
|
|
"email": identity.Email,
|
|
},
|
|
})
|
|
}
|
|
|
|
func isMicrosoftInteractionRequiredError(raw string) bool {
|
|
code := strings.ToLower(strings.TrimSpace(raw))
|
|
if code == "" {
|
|
return false
|
|
}
|
|
return strings.Contains(code, "aadsts16000") ||
|
|
code == "login_required" ||
|
|
code == "interaction_required" ||
|
|
code == "account_selection_required" ||
|
|
code == "consent_required"
|
|
}
|
|
|
|
// SSOLink godoc
|
|
// @Summary Link Microsoft SSO to current user
|
|
// @Description Link Microsoft SSO identity to the authenticated user. SSO login will only work after successful link.
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param request body dto.SSOLinkRequest true "JSON:API SSO link request"
|
|
// @Success 200 {object} dto.SSOLinkResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/sso/link [post]
|
|
func (h *AuthHandler) SSOLink(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
var req dto.SSOLinkRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
identity, err := h.svc.LinkSSOForUser(c.UserContext(), userID, req.Data.Attributes.Provider, req.Data.Attributes.Code)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrUserNotFound):
|
|
return writeAuthErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
|
|
Status: "404",
|
|
Title: "User not found",
|
|
Detail: "user not found",
|
|
}})
|
|
case errors.Is(err, service.ErrSSOAlreadyLinked):
|
|
return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
|
|
Status: "409",
|
|
Title: "SSO already linked",
|
|
Detail: "user already has an sso mapping",
|
|
}})
|
|
case errors.Is(err, service.ErrSSOLinkedToAnotherUser):
|
|
return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
|
|
Status: "409",
|
|
Title: "SSO linked to another user",
|
|
Detail: "this sso account is already linked to another user",
|
|
}})
|
|
case errors.Is(err, service.ErrUnsupportedSSOProvider):
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Unsupported SSO provider",
|
|
Detail: "unsupported sso provider",
|
|
}})
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "SSO link failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_sso_link",
|
|
Attributes: map[string]any{
|
|
"provider": identity.Provider,
|
|
"provider_subject": identity.ProviderSubject,
|
|
"email": identity.Email,
|
|
},
|
|
})
|
|
}
|
|
|
|
// SSOUnlink godoc
|
|
// @Summary Unlink Microsoft SSO from current user
|
|
// @Description Remove Microsoft SSO mapping from authenticated user.
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param request body dto.SSOUnlinkRequest true "JSON:API SSO unlink request"
|
|
// @Success 200 {object} dto.SSOUnlinkResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/sso/unlink [delete]
|
|
func (h *AuthHandler) SSOUnlink(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
var req dto.SSOUnlinkRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
if err := h.svc.UnlinkSSOForUser(c.UserContext(), userID, req.Data.Attributes.Provider); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrUserNotFound):
|
|
return writeAuthErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
|
|
Status: "404",
|
|
Title: "User not found",
|
|
Detail: "user not found",
|
|
}})
|
|
case errors.Is(err, service.ErrSSONotLinked):
|
|
return writeAuthErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
|
|
Status: "404",
|
|
Title: "SSO not linked",
|
|
Detail: "sso is not linked to this user",
|
|
}})
|
|
case errors.Is(err, service.ErrUnsupportedSSOProvider):
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Unsupported SSO provider",
|
|
Detail: "unsupported sso provider",
|
|
}})
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "SSO unlink failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_sso_unlink",
|
|
Attributes: map[string]any{
|
|
"provider": req.Data.Attributes.Provider,
|
|
"unlinked": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
func shouldUseBrowserRedirect(c *fiber.Ctx) bool {
|
|
if strings.EqualFold(strings.TrimSpace(c.Get("X-Requested-With")), "XMLHttpRequest") {
|
|
return false
|
|
}
|
|
|
|
mode := strings.ToLower(strings.TrimSpace(c.Get("Sec-Fetch-Mode")))
|
|
dest := strings.ToLower(strings.TrimSpace(c.Get("Sec-Fetch-Dest")))
|
|
if dest == "empty" || mode == "cors" || mode == "same-origin" {
|
|
return false
|
|
}
|
|
|
|
accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept)))
|
|
return !strings.Contains(accept, "application/json")
|
|
}
|
|
|
|
// WebAuthnRegisterBegin godoc
|
|
// @Summary Begin WebAuthn registration
|
|
// @Description Creates WebAuthn registration options for the authenticated user.
|
|
// @Tags Auth - WebAuthn
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.WebAuthnRegisterBeginRequest true "JSON:API WebAuthn register begin request"
|
|
// @Success 200 {object} dto.WebAuthnBeginResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/webauthn/register/begin [post]
|
|
func (h *AuthHandler) WebAuthnRegisterBegin(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
var req dto.WebAuthnRegisterBeginRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
options, challengeToken, err := h.svc.BeginWebAuthnRegistration(c.UserContext(), userID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrWebAuthnNotConfigured):
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "WebAuthn not configured",
|
|
Detail: "webauthn is not configured",
|
|
}})
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "WebAuthn begin failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_webauthn_register_options",
|
|
Attributes: map[string]any{
|
|
"challenge_token": challengeToken,
|
|
"challenge_ttl_ms": h.svc.WebAuthnChallengeTTL().Milliseconds(),
|
|
"public_key": options.Response,
|
|
},
|
|
})
|
|
}
|
|
|
|
// WebAuthnRegisterFinish godoc
|
|
// @Summary Finish WebAuthn registration
|
|
// @Description Verifies WebAuthn registration response and stores the credential.
|
|
// @Tags Auth - WebAuthn
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.WebAuthnRegisterFinishRequest true "JSON:API WebAuthn register finish request"
|
|
// @Success 200 {object} dto.AuthSessionsResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/webauthn/register/finish [post]
|
|
func (h *AuthHandler) WebAuthnRegisterFinish(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
var req dto.WebAuthnRegisterFinishRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
if err := h.svc.FinishWebAuthnRegistration(c.UserContext(), userID, req.Data.Attributes.ChallengeToken, req.Data.Attributes.Credential, req.Data.Attributes.Name); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrWebAuthnNotConfigured):
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "WebAuthn not configured",
|
|
Detail: "webauthn is not configured",
|
|
}})
|
|
case errors.Is(err, service.ErrWebAuthnChallengeInvalid):
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Invalid challenge",
|
|
Detail: "challenge token is invalid or expired",
|
|
}})
|
|
case errors.Is(err, service.ErrWebAuthnPayloadInvalid):
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "credential payload is invalid",
|
|
Source: &jsonapi.ErrorSource{
|
|
Pointer: "/data/attributes/credential",
|
|
},
|
|
}})
|
|
case errors.Is(err, service.ErrWebAuthnCredentialExists):
|
|
return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
|
|
Status: "409",
|
|
Title: "Credential already exists",
|
|
Detail: "credential has already been registered",
|
|
}})
|
|
case errors.Is(err, service.ErrWebAuthnVerificationFailed):
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Verification failed",
|
|
Detail: "unable to verify webauthn credential",
|
|
}})
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "WebAuthn registration failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_webauthn_register",
|
|
Attributes: map[string]any{
|
|
"registered": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// WebAuthnLoginBegin godoc
|
|
// @Summary Begin WebAuthn login
|
|
// @Description Creates WebAuthn assertion options for a user email.
|
|
// @Tags Auth - WebAuthn
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.WebAuthnLoginBeginRequest true "JSON:API WebAuthn login begin request"
|
|
// @Success 200 {object} dto.WebAuthnBeginResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/webauthn/login/begin [post]
|
|
func (h *AuthHandler) WebAuthnLoginBegin(c *fiber.Ctx) error {
|
|
var req dto.WebAuthnLoginBeginRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
options, challengeToken, err := h.svc.BeginWebAuthnLogin(c.UserContext(), req.Data.Attributes.Email)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrEmailNotVerified):
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "invalid credentials",
|
|
}})
|
|
case errors.Is(err, service.ErrWebAuthnCredentialUnavailable):
|
|
return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
|
|
Status: "409",
|
|
Title: "No WebAuthn credential",
|
|
Detail: "webauthn credential is not configured for this account",
|
|
}})
|
|
case errors.Is(err, service.ErrWebAuthnNotConfigured):
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "WebAuthn not configured",
|
|
Detail: "webauthn is not configured",
|
|
}})
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "WebAuthn begin failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_webauthn_login_options",
|
|
Attributes: map[string]any{
|
|
"challenge_token": challengeToken,
|
|
"challenge_ttl_ms": h.svc.WebAuthnChallengeTTL().Milliseconds(),
|
|
"public_key": options.Response,
|
|
},
|
|
})
|
|
}
|
|
|
|
// WebAuthnLoginFinish godoc
|
|
// @Summary Finish WebAuthn login
|
|
// @Description Verifies WebAuthn assertion and issues auth cookies.
|
|
// @Tags Auth - WebAuthn
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.WebAuthnLoginFinishRequest true "JSON:API WebAuthn login finish request"
|
|
// @Success 200 {object} dto.TokenResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/webauthn/login/finish [post]
|
|
func (h *AuthHandler) WebAuthnLoginFinish(c *fiber.Ctx) error {
|
|
var req dto.WebAuthnLoginFinishRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
user, err := h.svc.FinishWebAuthnLogin(c.UserContext(), req.Data.Attributes.ChallengeToken, req.Data.Attributes.Credential)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrWebAuthnChallengeInvalid):
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Invalid challenge",
|
|
Detail: "challenge token is invalid or expired",
|
|
}})
|
|
case errors.Is(err, service.ErrWebAuthnPayloadInvalid):
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "credential payload is invalid",
|
|
Source: &jsonapi.ErrorSource{
|
|
Pointer: "/data/attributes/credential",
|
|
},
|
|
}})
|
|
case errors.Is(err, service.ErrWebAuthnVerificationFailed), errors.Is(err, service.ErrInvalidCredentials):
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "invalid webauthn assertion",
|
|
}})
|
|
case errors.Is(err, service.ErrWebAuthnCredentialUnavailable):
|
|
return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
|
|
Status: "409",
|
|
Title: "No WebAuthn credential",
|
|
Detail: "webauthn credential is not configured for this account",
|
|
}})
|
|
case errors.Is(err, service.ErrWebAuthnNotConfigured):
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "WebAuthn not configured",
|
|
Detail: "webauthn is not configured",
|
|
}})
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "WebAuthn login failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
}
|
|
|
|
tokens, err := h.svc.IssueTokensWithClientAndDeviceType(
|
|
c.UserContext(),
|
|
user,
|
|
c.Get(fiber.HeaderUserAgent),
|
|
c.IP(),
|
|
readDeviceTypeHint(c),
|
|
)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Token issue failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
setAuthCookies(c, h.svc, tokens)
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_tokens",
|
|
Attributes: map[string]any{
|
|
"access_expires_in": int64(h.svc.AccessTTL().Seconds()),
|
|
"refresh_expires_in": int64(tokens.RefreshTTL.Seconds()),
|
|
},
|
|
})
|
|
}
|
|
|
|
// WebAuthnResetSetup godoc
|
|
// @Summary Reset WebAuthn setup
|
|
// @Description Removes all registered WebAuthn credentials so the user can re-setup passkeys.
|
|
// @Description For method=pin, obtain token from /auth/pin/verify with action=1f7d457e-0a19-4a36-8d9f-2c66fd2369ab and send it in X-PIN-Verification-Token header.
|
|
// @Tags Auth - WebAuthn
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.WebAuthnResetSetupRequest true "JSON:API WebAuthn reset setup request"
|
|
// @Success 200 {object} dto.AuthSessionDeleteResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 429 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/webauthn/reset-setup [post]
|
|
func (h *AuthHandler) WebAuthnResetSetup(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
var req dto.WebAuthnResetSetupRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method))
|
|
switch method {
|
|
case "pin":
|
|
sessionID := ""
|
|
if accessToken := readValidAccessToken(c, h.svc); accessToken != "" {
|
|
sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "invalid or expired token",
|
|
}})
|
|
}
|
|
sessionID = sid
|
|
}
|
|
token := c.Get(pinVerificationHeader)
|
|
if err := h.svc.ConsumeSecurityPINActionTokenInSession(c.UserContext(), userID, sharedconst.PinActionWebAuthnResetSetup, token, sessionID); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "PIN verification required",
|
|
Detail: "valid PIN action token is required",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/headers/X-PIN-Verification-Token"},
|
|
}})
|
|
}
|
|
if err := h.svc.DeleteWebAuthnSetup(c.UserContext(), userID); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "WebAuthn reset setup failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_webauthn_reset_setup",
|
|
Attributes: map[string]any{
|
|
"reset": true,
|
|
},
|
|
})
|
|
case "password":
|
|
if req.Data.Attributes.Password == "" {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "password is required when method is password",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"},
|
|
}})
|
|
}
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "method must be one of: pin, password",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
|
|
}})
|
|
}
|
|
|
|
if err := h.svc.ResetWebAuthnSetup(c.UserContext(), userID, method, "", req.Data.Attributes.Password); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrInvalidReauthMethod):
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "method must be one of: pin, password",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
|
|
}})
|
|
case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidSecurityPIN):
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "invalid authentication factor",
|
|
}})
|
|
case errors.Is(err, service.ErrSecurityPINNotSet):
|
|
return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
|
|
Status: "409",
|
|
Title: "PIN not configured",
|
|
Detail: "security PIN is not set",
|
|
}})
|
|
case errors.Is(err, service.ErrSecurityPINAttemptLimit):
|
|
return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{
|
|
Status: "423",
|
|
Title: "PIN blocked",
|
|
Detail: "security PIN is blocked. reset PIN is required.",
|
|
}})
|
|
case errors.Is(err, service.ErrSecurityPINLocked), errors.Is(err, service.ErrSecurityPINResetRequired):
|
|
return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{
|
|
Status: "423",
|
|
Title: "PIN blocked",
|
|
Detail: "security PIN is blocked. reset PIN is required.",
|
|
}})
|
|
case errors.Is(err, service.ErrInvalidToken):
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "user not found",
|
|
}})
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "WebAuthn reset setup failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_webauthn_reset_setup",
|
|
Attributes: map[string]any{
|
|
"reset": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Me godoc
|
|
// @Summary Get current user
|
|
// @Tags Auth
|
|
// @Produce json
|
|
// @Success 200 {object} dto.AuthCurrentUserResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/me [get]
|
|
func (h *AuthHandler) Me(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
user, err := h.svc.GetUserByID(c.UserContext(), userID)
|
|
if err != nil || user == nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "user not found",
|
|
}})
|
|
}
|
|
|
|
id, _ := uuidv7.BytesToString(user.ID)
|
|
var emailVerifiedAt string
|
|
if user.EmailVerifiedAt != nil {
|
|
emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
|
|
}
|
|
totpEnabled, _ := h.svc.IsTOTPEnabled(c.UserContext(), user.ID)
|
|
isPreviouslyConfigured, _ := h.svc.IsTOTPPreviouslyConfigured(c.UserContext(), user.ID)
|
|
webauthnConfigured, _ := h.svc.IsWebAuthnConfigured(c.UserContext(), user.ID)
|
|
linkedSSO, _ := h.svc.IsSSOLinked(c.UserContext(), user.ID)
|
|
microsoftScope, _ := h.svc.GetMicrosoftScopeByUserID(c.UserContext(), user.ID)
|
|
pinConfigured := len(user.SecurityPINHash) > 0
|
|
pinBlocked := h.svc.IsSecurityPINBlocked(c.UserContext(), user.ID)
|
|
loginMethod := "unknown"
|
|
authProvider := "unknown"
|
|
if accessToken := readValidAccessToken(c, h.svc); accessToken != "" {
|
|
if method, provider, err := h.svc.GetSessionAuthContextFromAccessToken(c.UserContext(), accessToken); err == nil {
|
|
loginMethod = method
|
|
authProvider = provider
|
|
}
|
|
}
|
|
_, roles := authRoleResponseData(h.svc, c, user)
|
|
permissions := permissionAttrs(h.svc, c, user.ID)
|
|
permissionModules := permissionModuleGroups(h.svc, c, user.ID)
|
|
foto := authUserFoto(c.UserContext(), h.storage, user)
|
|
dulBases := make([]map[string]any, 0)
|
|
if h.contactSvc != nil {
|
|
contactRow, contactErr := h.contactSvc.GetContactByID(c.UserContext(), user.ID)
|
|
if contactErr == nil && isPilotRoleContact(contactRow) {
|
|
if parsed := contactDULBases(contactRow.BaseRolesRaw); len(parsed) > 0 {
|
|
dulBases = parsed
|
|
}
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "user",
|
|
ID: id,
|
|
Attributes: map[string]any{
|
|
"email": user.Email,
|
|
"email_sso": derefString(user.SSOEmail),
|
|
"first_name": user.FirstName,
|
|
"last_name": user.LastName,
|
|
"mobile_phone": user.MobilePhone,
|
|
"timezone": tzpkg.WithDefault(user.Timezone),
|
|
"foto": foto,
|
|
"email_verified_at": emailVerifiedAt,
|
|
"roles": roles,
|
|
"permissions": permissions,
|
|
"permission_modules": permissionModules,
|
|
"totp_enabled": totpEnabled,
|
|
"is_previously_configured": isPreviouslyConfigured,
|
|
"webauthn_configured": webauthnConfigured,
|
|
"linked_sso": linkedSSO,
|
|
"pin_configured": pinConfigured,
|
|
"pin_blocked": pinBlocked,
|
|
"login_method": loginMethod,
|
|
"auth_provider": authProvider,
|
|
"microsoft_scope": microsoftScope,
|
|
"dul_bases": dulBases,
|
|
},
|
|
})
|
|
}
|
|
|
|
// UpdateTimezone godoc
|
|
// @Summary Update current user timezone
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.TimezoneUpdateRequest true "JSON:API timezone update request"
|
|
// @Success 200 {object} dto.AuthCurrentUserResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/me/timezone [patch]
|
|
func (h *AuthHandler) UpdateTimezone(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
var req dto.TimezoneUpdateRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Invalid JSON",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
normalizedTimezone, err := tzpkg.Normalize(req.Data.Attributes.Timezone)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, timezoneValidationError("/data/attributes/timezone", err))
|
|
}
|
|
if _, err := h.svc.UpdateUserTimezone(c.UserContext(), userID, normalizedTimezone); err != nil {
|
|
if errors.Is(err, tzpkg.ErrEmpty) || errors.Is(err, tzpkg.ErrInvalid) {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, timezoneValidationError("/data/attributes/timezone", err))
|
|
}
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "Update failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
|
|
user, err := h.svc.GetUserByID(c.UserContext(), userID)
|
|
if err != nil || user == nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "user not found",
|
|
}})
|
|
}
|
|
|
|
id, _ := uuidv7.BytesToString(user.ID)
|
|
var emailVerifiedAt string
|
|
if user.EmailVerifiedAt != nil {
|
|
emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
|
|
}
|
|
|
|
_, roles := authRoleResponseData(h.svc, c, user)
|
|
permissions := permissionAttrs(h.svc, c, user.ID)
|
|
permissionModules := permissionModuleGroups(h.svc, c, user.ID)
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "user",
|
|
ID: id,
|
|
Attributes: map[string]any{
|
|
"email": user.Email,
|
|
"first_name": user.FirstName,
|
|
"last_name": user.LastName,
|
|
"mobile_phone": user.MobilePhone,
|
|
"timezone": tzpkg.WithDefault(user.Timezone),
|
|
"email_verified_at": emailVerifiedAt,
|
|
"roles": roles,
|
|
"permissions": permissions,
|
|
"permission_modules": permissionModules,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Refresh godoc
|
|
// @Summary Refresh access token
|
|
// @Tags Auth - Refresh
|
|
// @Produce json
|
|
// @Success 200 {object} dto.TokenResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/refresh [post]
|
|
func (h *AuthHandler) Refresh(c *fiber.Ctx) error {
|
|
var (
|
|
tokens service.TokenPair
|
|
err error
|
|
)
|
|
refreshCandidates := service.CookieValues(c.Get(fiber.HeaderCookie), h.svc.RefreshCookieName())
|
|
if len(refreshCandidates) == 0 {
|
|
refreshCandidates = []string{strings.TrimSpace(c.Cookies(h.svc.RefreshCookieName()))}
|
|
}
|
|
for _, refreshToken := range refreshCandidates {
|
|
tokens, err = h.svc.RefreshTokensWithClientAndDeviceType(
|
|
c.UserContext(),
|
|
refreshToken,
|
|
c.Get(fiber.HeaderUserAgent),
|
|
c.IP(),
|
|
readDeviceTypeHint(c),
|
|
)
|
|
if err == nil {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
setAuthCookies(c, h.svc, tokens)
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_tokens",
|
|
Attributes: map[string]any{
|
|
"access_expires_in": int64(h.svc.AccessTTL().Seconds()),
|
|
"refresh_expires_in": int64(tokens.RefreshTTL.Seconds()),
|
|
"microsoft": map[string]any{
|
|
"status": "checked_during_refresh",
|
|
"token": map[string]any{},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// Logout godoc
|
|
// @Summary Logout (revoke refresh)
|
|
// @Tags Auth - Refresh
|
|
// @Produce json
|
|
// @Success 200 {object} dto.VerifyEmailResponse
|
|
// @Router /api/v1/auth/logout [post]
|
|
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
|
|
accessToken := readValidAccessToken(c, h.svc)
|
|
refreshToken := readValidRefreshToken(c, h.svc)
|
|
if accessToken != "" {
|
|
if userID, err := h.svc.ParseAccessToken(c.UserContext(), accessToken); err == nil && len(userID) == 16 {
|
|
_ = h.svc.RevokeAllUserSessions(c.UserContext(), userID)
|
|
}
|
|
}
|
|
_ = h.svc.RevokeRefreshToken(c.UserContext(), refreshToken)
|
|
clearAuthCookies(c, h.svc)
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_logout",
|
|
Attributes: map[string]any{
|
|
"logged_out": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// MicrosoftRefresh godoc
|
|
// @Summary Refresh Microsoft access token
|
|
// @Tags Auth - SSO
|
|
// @Produce json
|
|
// @Success 200 {object} dto.TokenResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 428 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/microsoft/refresh [post]
|
|
func (h *AuthHandler) MicrosoftRefresh(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
tokenSet, err := h.svc.RefreshMicrosoftAccessToken(c.UserContext(), userID)
|
|
if err != nil {
|
|
if errors.Is(err, service.ErrMicrosoftReauthRequired) {
|
|
return writeAuthErrors(c, fiber.StatusPreconditionRequired, []jsonapi.ErrorObject{{
|
|
Status: "428",
|
|
Title: "Reauthentication required",
|
|
Detail: "microsoft session requires reauthentication",
|
|
}})
|
|
}
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_microsoft_tokens",
|
|
Attributes: map[string]any{
|
|
"access_token": tokenSet.AccessToken,
|
|
"token_type": tokenSet.TokenType,
|
|
"scope": tokenSet.Scope,
|
|
"expires_in": tokenSet.ExpiresIn,
|
|
},
|
|
})
|
|
}
|
|
|
|
// MicrosoftLogout godoc
|
|
// @Summary Logout Microsoft OAuth session
|
|
// @Tags Auth - SSO
|
|
// @Produce json
|
|
// @Success 200 {object} dto.VerifyEmailResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/microsoft/logout [post]
|
|
func (h *AuthHandler) MicrosoftLogout(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
if err := h.svc.LogoutMicrosoft(c.UserContext(), userID); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
_ = h.svc.RevokeRefreshToken(c.UserContext(), readValidRefreshToken(c, h.svc))
|
|
clearAuthCookies(c, h.svc)
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_microsoft_logout",
|
|
Attributes: map[string]any{
|
|
"logged_out": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// MicrosoftFrontChannelLogout godoc
|
|
// @Summary Microsoft front-channel logout callback
|
|
// @Description OIDC front-channel logout endpoint. Configure this URL as the "Front-channel logout URL" in Microsoft Entra. Microsoft loads it in a hidden iframe when the user signs out at the IdP (or from another RP). It clears local auth cookies and revokes the server-side session plus the stored Microsoft refresh token for the browser that owns the request. It never redirects and always returns 200 so the IdP logout flow can complete.
|
|
// @Tags Auth - SSO
|
|
// @Produce html
|
|
// @Success 200 {string} string "logged out"
|
|
// @Router /api/v1/auth/microsoft/front-channel-logout [get]
|
|
func (h *AuthHandler) MicrosoftFrontChannelLogout(c *fiber.Ctx) error {
|
|
// This endpoint is loaded by the IdP inside an iframe. Responses must not be
|
|
// cached and must not redirect (a redirect would break the IdP logout flow).
|
|
c.Set(fiber.HeaderCacheControl, "no-store, max-age=0")
|
|
c.Set(fiber.HeaderPragma, "no-cache")
|
|
c.Set(fiber.HeaderXFrameOptions, "")
|
|
c.Set("Content-Security-Policy", "frame-ancestors https://login.microsoftonline.com https://*.microsoftonline.com https://login.live.com")
|
|
|
|
// OIDC front-channel logout MAY include iss (issuer) and sid (session id). When an
|
|
// issuer is supplied it must look like a Microsoft issuer; otherwise ignore the
|
|
// request body but still answer 200 as the spec requires.
|
|
if iss := strings.TrimSpace(c.Query("iss")); iss != "" && !isMicrosoftIssuer(iss) {
|
|
return sendFrontChannelLogoutOK(c)
|
|
}
|
|
|
|
revoked := false
|
|
if sid := strings.TrimSpace(c.Query("sid")); sid != "" {
|
|
if err := h.svc.RevokeMicrosoftSessionBySID(c.UserContext(), sid); err == nil {
|
|
revoked = true
|
|
}
|
|
}
|
|
|
|
// Cookie-based revocation remains as a fallback for browsers that still
|
|
// send the app cookies inside the front-channel iframe.
|
|
if accessToken := readValidAccessToken(c, h.svc); accessToken != "" {
|
|
if userID, err := h.svc.ParseAccessToken(c.UserContext(), accessToken); err == nil && len(userID) == 16 {
|
|
_ = h.svc.RevokeAllUserSessions(c.UserContext(), userID)
|
|
_ = h.svc.LogoutMicrosoft(c.UserContext(), userID)
|
|
revoked = true
|
|
}
|
|
}
|
|
if refreshToken := readValidRefreshToken(c, h.svc); refreshToken != "" {
|
|
_ = h.svc.RevokeRefreshToken(c.UserContext(), refreshToken)
|
|
revoked = true
|
|
}
|
|
clearAuthCookiesForFrontChannel(c, h.svc)
|
|
_ = revoked
|
|
return sendFrontChannelLogoutOK(c)
|
|
}
|
|
|
|
func sendFrontChannelLogoutOK(c *fiber.Ctx) error {
|
|
c.Set(fiber.HeaderContentType, "text/html; charset=utf-8")
|
|
return c.Status(fiber.StatusOK).SendString(`<!doctype html><meta charset="utf-8"><title>Logged out</title>`)
|
|
}
|
|
|
|
// isMicrosoftIssuer reports whether iss is a plausible Microsoft Entra issuer URL.
|
|
func isMicrosoftIssuer(iss string) bool {
|
|
iss = strings.TrimSpace(iss)
|
|
if iss == "" {
|
|
return false
|
|
}
|
|
u, err := url.Parse(iss)
|
|
if err != nil || u.Scheme != "https" {
|
|
return false
|
|
}
|
|
host := strings.ToLower(u.Hostname())
|
|
return host == "login.microsoftonline.com" ||
|
|
host == "sts.windows.net" ||
|
|
strings.HasSuffix(host, ".microsoftonline.com") ||
|
|
strings.HasSuffix(host, ".windows.net")
|
|
}
|
|
|
|
// Sessions godoc
|
|
// @Summary List active sessions
|
|
// @Description Lists all active sessions/devices for current user.
|
|
// @Tags Auth - Refresh
|
|
// @Produce json
|
|
// @Success 200 {object} dto.AuthSessionsResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/sessions [get]
|
|
func (h *AuthHandler) Sessions(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
currentSessionID := ""
|
|
if accessToken := readValidAccessToken(c, h.svc); strings.TrimSpace(accessToken) != "" {
|
|
sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
|
|
if err == nil {
|
|
currentSessionID = sid
|
|
}
|
|
}
|
|
|
|
sessions, err := h.svc.ListUserSessions(c.UserContext(), userID, currentSessionID)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
|
Status: "400",
|
|
Title: "List sessions failed",
|
|
Detail: "an internal error occurred",
|
|
}})
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_sessions",
|
|
Attributes: map[string]any{
|
|
"sessions": sessions,
|
|
},
|
|
})
|
|
}
|
|
|
|
// DeleteSession godoc
|
|
// @Summary Delete one session
|
|
// @Description Revokes one session/device immediately. Requires PIN action token (header X-PIN-Verification-Token) with action=3d7d1d5f-ec48-4eca-a7ea-37f4f7927d06.
|
|
// @Tags Auth - Refresh
|
|
// @Produce json
|
|
// @Param session_id path string true "Session ID"
|
|
// @Success 200 {object} dto.AuthSessionDeleteResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 404 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/sessions/{session_id} [delete]
|
|
func (h *AuthHandler) DeleteSession(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
sessionID := strings.TrimSpace(c.Params("session_id"))
|
|
if _, err := uuidv7.ParseString(sessionID); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "session_id is invalid UUID",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/path/session_id"},
|
|
}})
|
|
}
|
|
|
|
if err := h.svc.RevokeUserSession(c.UserContext(), userID, sessionID); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrSessionNotFound):
|
|
return writeHandlerErrorDef(c, "AuthHandler.DeleteSession.SessionNotFound", apperrorsx.ErrDataNotFound, err)
|
|
case errors.Is(err, service.ErrInvalidToken):
|
|
return writeHandlerErrorDef(c, "AuthHandler.DeleteSession.InvalidToken", apperrorsx.ErrUserUnauthorized, err)
|
|
default:
|
|
return writeHandlerErrorDefWithStatus(c, "AuthHandler.DeleteSession", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
|
|
}
|
|
}
|
|
|
|
if accessToken := readValidAccessToken(c, h.svc); strings.TrimSpace(accessToken) != "" {
|
|
sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
|
|
if err == nil && sid == sessionID {
|
|
clearAuthCookies(c, h.svc)
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_session_delete",
|
|
Attributes: map[string]any{
|
|
"deleted": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// TOTPSetup godoc
|
|
// @Summary Setup TOTP
|
|
// @Description Generates secret and otpauth URL. TOTP is NOT enabled until confirmed.
|
|
// @Tags Auth - TOTP
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.TOTPSetupRequest true "JSON:API TOTP setup request"
|
|
// @Success 200 {object} dto.TOTPSetupResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/totp/setup [post]
|
|
func (h *AuthHandler) TOTPSetup(c *fiber.Ctx) error {
|
|
authUserID := actorUserID(c)
|
|
if len(authUserID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
var req dto.TOTPSetupRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeHandlerErrorDef(c, "AuthHandler.TOTPSetup.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "user_id is invalid UUID",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
|
|
}})
|
|
}
|
|
if !bytes.Equal(userID, authUserID) {
|
|
return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
|
|
Status: "403",
|
|
Title: "Forbidden",
|
|
Detail: "user_id does not match authenticated user",
|
|
}})
|
|
}
|
|
|
|
user, err := h.svc.GetUserByID(c.UserContext(), authUserID)
|
|
if err != nil || user == nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "authenticated user not found",
|
|
}})
|
|
}
|
|
|
|
secret, otpURL, issuer, err := h.svc.SetupTOTP(c.UserContext(), authUserID, user.Email)
|
|
if err != nil {
|
|
return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPSetup", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_totp_setup",
|
|
Attributes: map[string]any{
|
|
"secret": secret,
|
|
"otpauth_url": otpURL,
|
|
"issuer": issuer,
|
|
},
|
|
})
|
|
}
|
|
|
|
// TOTPConfirm godoc
|
|
// @Summary Confirm TOTP setup
|
|
// @Description Validates initial TOTP code and enables TOTP for future logins.
|
|
// @Tags Auth - TOTP
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.TOTPConfirmRequest true "JSON:API TOTP confirm request"
|
|
// @Success 200 {object} dto.VerifyEmailResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/totp/confirm [post]
|
|
func (h *AuthHandler) TOTPConfirm(c *fiber.Ctx) error {
|
|
authUserID := actorUserID(c)
|
|
if len(authUserID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
var req dto.TOTPConfirmRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeHandlerErrorDef(c, "AuthHandler.TOTPConfirm.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "user_id is invalid UUID",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
|
|
}})
|
|
}
|
|
if !bytes.Equal(userID, authUserID) {
|
|
return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
|
|
Status: "403",
|
|
Title: "Forbidden",
|
|
Detail: "user_id does not match authenticated user",
|
|
}})
|
|
}
|
|
|
|
ok, err := h.svc.ConfirmTOTP(c.UserContext(), authUserID, strings.TrimSpace(req.Data.Attributes.Code))
|
|
if err != nil || !ok {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Invalid TOTP",
|
|
Detail: "code is invalid or expired",
|
|
}})
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_totp_confirm",
|
|
Attributes: map[string]any{
|
|
"confirmed": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// TOTPVerify godoc
|
|
// @Summary Verify TOTP code
|
|
// @Description Completes login when TOTP is enabled and returns auth cookies.
|
|
// @Tags Auth - TOTP
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.TOTPVerifyRequest true "JSON:API TOTP verify request"
|
|
// @Success 200 {object} dto.TokenResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/totp/verify [post]
|
|
func (h *AuthHandler) TOTPVerify(c *fiber.Ctx) error {
|
|
var req dto.TOTPVerifyRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeHandlerErrorDef(c, "AuthHandler.TOTPVerify.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
challengeToken := strings.TrimSpace(req.Data.Attributes.ChallengeToken)
|
|
userID, err := h.svc.ResolveTOTPChallenge(c.UserContext(), challengeToken)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Invalid challenge",
|
|
Detail: "challenge token is invalid or expired",
|
|
}})
|
|
}
|
|
|
|
ok, err := h.svc.VerifyTOTP(c.UserContext(), userID, strings.TrimSpace(req.Data.Attributes.Code))
|
|
if err != nil || !ok {
|
|
if _, attemptErr := h.svc.RegisterFailedTOTPChallengeAttempt(c.UserContext(), challengeToken); attemptErr != nil {
|
|
if errors.Is(attemptErr, service.ErrTOTPChallengeAttemptLimit) {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Invalid challenge",
|
|
Detail: "challenge token is invalid or expired",
|
|
}})
|
|
}
|
|
}
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Invalid TOTP",
|
|
Detail: "code is invalid or expired",
|
|
}})
|
|
}
|
|
h.svc.InvalidateTOTPChallenge(c.UserContext(), challengeToken)
|
|
|
|
user, err := h.svc.GetUserByID(c.UserContext(), userID)
|
|
if err != nil || user == nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "user not found",
|
|
}})
|
|
}
|
|
|
|
tokens, err := h.svc.IssueTokensAfterTOTPWithClientAndDeviceType(
|
|
c.UserContext(),
|
|
user,
|
|
c.Get(fiber.HeaderUserAgent),
|
|
c.IP(),
|
|
readDeviceTypeHint(c),
|
|
)
|
|
if err != nil {
|
|
return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPVerify.IssueTokens", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
|
|
}
|
|
setAuthCookies(c, h.svc, tokens)
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_tokens",
|
|
Attributes: map[string]any{
|
|
"access_expires_in": int64(h.svc.AccessTTL().Seconds()),
|
|
"refresh_expires_in": int64(tokens.RefreshTTL.Seconds()),
|
|
},
|
|
})
|
|
}
|
|
|
|
// SendEmailOTP godoc
|
|
// @Summary Send login OTP to email
|
|
// @Description Sends a one-time 6-digit OTP to user email using login challenge token from /auth/login.
|
|
// @Description Security controls: old OTP is invalidated, resend cooldown is enforced, and resend/hour quota is enforced.
|
|
// @Tags Auth - Email OTP
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.EmailOTPSendRequest true "JSON:API Email OTP send request"
|
|
// @Success 200 {object} dto.EmailOTPSendResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 429 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/email-otp/send [post]
|
|
func (h *AuthHandler) SendEmailOTP(c *fiber.Ctx) error {
|
|
if !h.svc.IsLoginEmailOTPEnabled(c.UserContext()) {
|
|
return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
|
|
Status: "403",
|
|
Title: "Email OTP disabled",
|
|
Detail: "email otp login is disabled",
|
|
}})
|
|
}
|
|
var req dto.EmailOTPSendRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeHandlerErrorDef(c, "AuthHandler.SendEmailOTP.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
if err := h.svc.SendLoginEmailOTP(c.UserContext(), strings.TrimSpace(req.Data.Attributes.ChallengeToken)); err != nil {
|
|
if errors.Is(err, service.ErrEmailOTPRateLimited) || errors.Is(err, service.ErrEmailOTPResendExceeded) {
|
|
return writeHandlerErrorDefWithStatus(c, "AuthHandler.SendEmailOTP.RateLimited", fiber.StatusTooManyRequests, apperrorsx.ErrBusinessRuleFailed, err)
|
|
}
|
|
return writeHandlerErrorDefWithStatus(c, "AuthHandler.SendEmailOTP", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_email_otp_send",
|
|
Attributes: map[string]any{
|
|
"sent": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// VerifyEmailOTP godoc
|
|
// @Summary Verify login OTP from email
|
|
// @Description Verifies one-time OTP and completes login by issuing auth cookies.
|
|
// @Description OTP is one-time use, expires automatically, and failed attempts are limited and audited.
|
|
// @Tags Auth - Email OTP
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.EmailOTPVerifyRequest true "JSON:API Email OTP verify request"
|
|
// @Success 200 {object} dto.EmailOTPVerifyResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/email-otp/verify [post]
|
|
func (h *AuthHandler) VerifyEmailOTP(c *fiber.Ctx) error {
|
|
if !h.svc.IsLoginEmailOTPEnabled(c.UserContext()) {
|
|
return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
|
|
Status: "403",
|
|
Title: "Email OTP disabled",
|
|
Detail: "email otp login is disabled",
|
|
}})
|
|
}
|
|
var req dto.EmailOTPVerifyRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeHandlerErrorDef(c, "AuthHandler.VerifyEmailOTP.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
user, err := h.svc.VerifyLoginEmailOTP(c.UserContext(), strings.TrimSpace(req.Data.Attributes.ChallengeToken), strings.TrimSpace(req.Data.Attributes.Code))
|
|
if err != nil {
|
|
if errors.Is(err, service.ErrInvalidEmailOTP) {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "invalid otp code",
|
|
}})
|
|
}
|
|
return writeHandlerErrorDefWithStatus(c, "AuthHandler.VerifyEmailOTP", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
|
|
}
|
|
tokens, err := h.svc.IssueTokensAfterTOTPWithClientAndDeviceType(
|
|
c.UserContext(),
|
|
user,
|
|
c.Get(fiber.HeaderUserAgent),
|
|
c.IP(),
|
|
readDeviceTypeHint(c),
|
|
)
|
|
if err != nil {
|
|
return writeHandlerErrorDefWithStatus(c, "AuthHandler.VerifyEmailOTP.IssueTokens", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
|
|
}
|
|
setAuthCookies(c, h.svc, tokens)
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_email_otp_verify",
|
|
Attributes: map[string]any{
|
|
"verified": true,
|
|
"requires_totp_setup": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// TOTPDisable godoc
|
|
// @Summary Disable TOTP
|
|
// @Tags Auth - TOTP
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.TOTPDisableRequest true "JSON:API TOTP disable request"
|
|
// @Success 200 {object} dto.VerifyEmailResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/totp/disable [post]
|
|
func (h *AuthHandler) TOTPDisable(c *fiber.Ctx) error {
|
|
authUserID := actorUserID(c)
|
|
if len(authUserID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
var req dto.TOTPDisableRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeHandlerErrorDef(c, "AuthHandler.TOTPDisable.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "user_id is invalid UUID",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
|
|
}})
|
|
}
|
|
if !bytes.Equal(userID, authUserID) {
|
|
return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
|
|
Status: "403",
|
|
Title: "Forbidden",
|
|
Detail: "user_id does not match authenticated user",
|
|
}})
|
|
}
|
|
|
|
if err := h.svc.DisableTOTP(c.UserContext(), authUserID); err != nil {
|
|
return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPDisable", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_totp_disable",
|
|
Attributes: map[string]any{
|
|
"disabled": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// TOTPResetSetup godoc
|
|
// @Summary Reset TOTP setup
|
|
// @Description Removes TOTP setup so the user can re-setup authenticator.
|
|
// @Description For method=pin, obtain token from /auth/pin/verify with action=0b5409b5-94f3-4aab-9de2-44b0ad5858f1 and send it in X-PIN-Verification-Token header.
|
|
// @Tags Auth - TOTP
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.TOTPResetSetupRequest true "JSON:API TOTP reset setup request"
|
|
// @Success 200 {object} dto.VerifyEmailResponse
|
|
// @Failure 401 {object} dto.ErrorResponse
|
|
// @Failure 409 {object} dto.ErrorResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 429 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/totp/reset-setup [post]
|
|
func (h *AuthHandler) TOTPResetSetup(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").([]byte)
|
|
if !ok || len(userID) == 0 {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "missing auth context",
|
|
}})
|
|
}
|
|
|
|
var req dto.TOTPResetSetupRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeHandlerErrorDef(c, "AuthHandler.TOTPResetSetup.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
|
|
method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method))
|
|
switch method {
|
|
case "pin":
|
|
sessionID := ""
|
|
if accessToken := readValidAccessToken(c, h.svc); accessToken != "" {
|
|
sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
|
|
if err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "invalid or expired token",
|
|
}})
|
|
}
|
|
sessionID = sid
|
|
}
|
|
token := c.Get(pinVerificationHeader)
|
|
if err := h.svc.ConsumeSecurityPINActionTokenInSession(c.UserContext(), userID, sharedconst.PinActionTOTPResetSetup, token, sessionID); err != nil {
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "PIN verification required",
|
|
Detail: "valid PIN action token is required",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/headers/X-PIN-Verification-Token"},
|
|
}})
|
|
}
|
|
if err := h.svc.DeleteTOTPSetup(c.UserContext(), userID); err != nil {
|
|
return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPResetSetup.Delete", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
|
|
}
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_totp_reset_setup",
|
|
Attributes: map[string]any{
|
|
"reset": true,
|
|
},
|
|
})
|
|
case "password":
|
|
if req.Data.Attributes.Password == "" {
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "password is required when method is password",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"},
|
|
}})
|
|
}
|
|
default:
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "method must be one of: pin, password",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
|
|
}})
|
|
}
|
|
|
|
if err := h.svc.ResetTOTPSetup(c.UserContext(), userID, method, "", req.Data.Attributes.Password); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrInvalidReauthMethod):
|
|
return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "method must be one of: pin, password",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
|
|
}})
|
|
case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidSecurityPIN):
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "invalid authentication factor",
|
|
}})
|
|
case errors.Is(err, service.ErrSecurityPINNotSet):
|
|
return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
|
|
Status: "409",
|
|
Title: "PIN not configured",
|
|
Detail: "security PIN is not set",
|
|
}})
|
|
case errors.Is(err, service.ErrSecurityPINAttemptLimit):
|
|
return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{
|
|
Status: "423",
|
|
Title: "PIN blocked",
|
|
Detail: "security PIN is blocked. reset PIN is required.",
|
|
}})
|
|
case errors.Is(err, service.ErrSecurityPINLocked), errors.Is(err, service.ErrSecurityPINResetRequired):
|
|
return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{
|
|
Status: "423",
|
|
Title: "PIN blocked",
|
|
Detail: "security PIN is blocked. reset PIN is required.",
|
|
}})
|
|
case errors.Is(err, service.ErrInvalidToken):
|
|
return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
|
|
Status: "401",
|
|
Title: "Unauthorized",
|
|
Detail: "user not found",
|
|
}})
|
|
default:
|
|
return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPResetSetup", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
|
|
}
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
|
|
Type: "auth_totp_reset_setup",
|
|
Attributes: map[string]any{
|
|
"reset": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
func permissionAttrs(svc *service.AuthService, c *fiber.Ctx, userID []byte) []map[string]any {
|
|
perms, err := svc.GetPermissionsByUserID(c.UserContext(), userID)
|
|
if err != nil {
|
|
return []map[string]any{}
|
|
}
|
|
out := make([]map[string]any, 0, len(perms))
|
|
for i := range perms {
|
|
pid, _ := uuidv7.BytesToString(perms[i].ID)
|
|
out = append(out, map[string]any{
|
|
"id": pid,
|
|
"name": perms[i].Name,
|
|
"key": perms[i].Key,
|
|
"description": perms[i].Description,
|
|
"requires_pin": perms[i].RequiresPIN,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// permissionModuleGroups returns the user's permissions grouped by module, in the
|
|
// same shape as GET /roles/permissions/get-all.
|
|
// TODO: remove permissionAttrs (the flat "permissions" field) once the frontend
|
|
// migrates to "permission_modules".
|
|
func permissionModuleGroups(svc *service.AuthService, c *fiber.Ctx, userID []byte) []dto.PermissionGroupResource {
|
|
perms, err := svc.GetPermissionsByUserID(c.UserContext(), userID)
|
|
if err != nil {
|
|
return []dto.PermissionGroupResource{}
|
|
}
|
|
return groupPermissionsByModule(perms)
|
|
}
|
|
|
|
func authRoleResponseData(svc *service.AuthService, c *fiber.Ctx, user *auth.User) (any, []map[string]any) {
|
|
roleIDs := make([]string, 0, len(user.RoleIDs)+1)
|
|
roles := make([]map[string]any, 0, len(user.Roles)+1)
|
|
seen := make(map[string]struct{}, len(user.RoleIDs)+1)
|
|
|
|
appendRole := func(roleID []byte, name, description string) {
|
|
if len(roleID) != 16 {
|
|
return
|
|
}
|
|
roleIDStr, _ := uuidv7.BytesToString(roleID)
|
|
if _, exists := seen[roleIDStr]; exists {
|
|
return
|
|
}
|
|
seen[roleIDStr] = struct{}{}
|
|
roleIDs = append(roleIDs, roleIDStr)
|
|
roles = append(roles, map[string]any{
|
|
"id": roleIDStr,
|
|
"name": name,
|
|
"description": description,
|
|
})
|
|
}
|
|
|
|
primaryRole := findAuthRoleByID(user, user.RoleID)
|
|
if primaryRole == nil && len(user.RoleID) == 16 {
|
|
primaryRole, _ = svc.GetRoleByID(c.UserContext(), user.RoleID)
|
|
}
|
|
if primaryRole != nil {
|
|
appendRole(primaryRole.ID, primaryRole.Name, primaryRole.Description)
|
|
}
|
|
|
|
for i := range user.Roles {
|
|
appendRole(user.Roles[i].ID, user.Roles[i].Name, user.Roles[i].Description)
|
|
}
|
|
for i := range user.RoleIDs {
|
|
appendRole(user.RoleIDs[i], "", "")
|
|
}
|
|
if len(roles) == 0 && len(user.RoleID) == 16 {
|
|
appendRole(user.RoleID, "", "")
|
|
}
|
|
|
|
// primaryRoleID := ""
|
|
var primaryRoleAttrs any
|
|
if len(roles) > 0 {
|
|
// primaryRoleID = roleIDs[0]
|
|
primaryRoleAttrs = roles[0]
|
|
}
|
|
|
|
return primaryRoleAttrs, roles
|
|
}
|
|
|
|
func roleResponseIDFields(roles []map[string]any) (any, []string) {
|
|
roleIDs := make([]string, 0, len(roles))
|
|
for i := range roles {
|
|
if roleID, ok := roles[i]["id"].(string); ok && roleID != "" {
|
|
roleIDs = append(roleIDs, roleID)
|
|
}
|
|
}
|
|
if len(roleIDs) == 0 {
|
|
return nil, roleIDs
|
|
}
|
|
return roleIDs[0], roleIDs
|
|
}
|
|
|
|
func findAuthRoleByID(user *auth.User, roleID []byte) *auth.Role {
|
|
if user == nil || len(roleID) != 16 {
|
|
return nil
|
|
}
|
|
target := string(roleID)
|
|
for i := range user.Roles {
|
|
if string(user.Roles[i].ID) == target {
|
|
role := user.Roles[i]
|
|
return &role
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func setAuthCookies(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair) {
|
|
setAuthCookiesWithSameSite(c, svc, tokens, parseSameSite(svc.CookieSameSite()), svc.CookieSecure())
|
|
}
|
|
|
|
func setAuthCookiesForProvider(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair, authProvider string) {
|
|
sameSite := parseSameSite(svc.CookieSameSite())
|
|
secure := svc.CookieSecure()
|
|
if strings.EqualFold(strings.TrimSpace(authProvider), "microsoft") && secure {
|
|
sameSite = "None"
|
|
}
|
|
setAuthCookiesWithSameSite(c, svc, tokens, sameSite, secure)
|
|
}
|
|
|
|
func setAuthCookiesWithSameSite(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair, sameSite string, secure bool) {
|
|
refreshTTL := tokens.RefreshTTL
|
|
if refreshTTL <= 0 {
|
|
refreshTTL = svc.RefreshTTL()
|
|
}
|
|
clearAuthCookieScopes(c, svc, sameSite, secure)
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: svc.AccessCookieName(),
|
|
Value: tokens.AccessToken,
|
|
HTTPOnly: true,
|
|
Secure: secure,
|
|
SameSite: sameSite,
|
|
Domain: svc.CookieDomain(),
|
|
Path: "/",
|
|
MaxAge: int(svc.AccessTTL().Seconds()),
|
|
})
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: svc.RefreshCookieName(),
|
|
Value: tokens.RefreshToken,
|
|
HTTPOnly: true,
|
|
Secure: secure,
|
|
SameSite: sameSite,
|
|
Domain: svc.CookieDomain(),
|
|
Path: "/api/v1/auth/refresh",
|
|
MaxAge: int(refreshTTL.Seconds()),
|
|
})
|
|
}
|
|
|
|
func clearAuthCookieScopes(c *fiber.Ctx, svc *service.AuthService, sameSite string, secure bool) {
|
|
name := svc.AccessCookieName()
|
|
refreshName := svc.RefreshCookieName()
|
|
domain := strings.TrimSpace(svc.CookieDomain())
|
|
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: name,
|
|
Value: "",
|
|
HTTPOnly: true,
|
|
Secure: secure,
|
|
SameSite: sameSite,
|
|
Domain: domain,
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
})
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: refreshName,
|
|
Value: "",
|
|
HTTPOnly: true,
|
|
Secure: secure,
|
|
SameSite: sameSite,
|
|
Domain: domain,
|
|
Path: "/api/v1/auth/refresh",
|
|
MaxAge: -1,
|
|
})
|
|
if domain != "" {
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: name,
|
|
Value: "",
|
|
HTTPOnly: true,
|
|
Secure: secure,
|
|
SameSite: sameSite,
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
})
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: refreshName,
|
|
Value: "",
|
|
HTTPOnly: true,
|
|
Secure: secure,
|
|
SameSite: sameSite,
|
|
Path: "/api/v1/auth/refresh",
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
}
|
|
|
|
func readValidAccessToken(c *fiber.Ctx, svc *service.AuthService) string {
|
|
if c == nil || svc == nil {
|
|
return ""
|
|
}
|
|
candidates := service.CookieValues(c.Get(fiber.HeaderCookie), svc.AccessCookieName())
|
|
for _, token := range candidates {
|
|
if _, err := svc.ParseAccessToken(c.UserContext(), token); err == nil {
|
|
return token
|
|
}
|
|
}
|
|
return strings.TrimSpace(c.Cookies(svc.AccessCookieName()))
|
|
}
|
|
|
|
func readValidRefreshToken(c *fiber.Ctx, svc *service.AuthService) string {
|
|
if c == nil || svc == nil {
|
|
return ""
|
|
}
|
|
candidates := service.CookieValues(c.Get(fiber.HeaderCookie), svc.RefreshCookieName())
|
|
if len(candidates) == 0 {
|
|
return strings.TrimSpace(c.Cookies(svc.RefreshCookieName()))
|
|
}
|
|
return candidates[0]
|
|
}
|
|
|
|
func writeAuthErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error {
|
|
enriched := make([]jsonapi.ErrorObject, 0, len(errs))
|
|
for i := range errs {
|
|
e := errs[i]
|
|
if strings.TrimSpace(e.Code) == "" || strings.TrimSpace(e.ErrorCode) == "" {
|
|
code, errorCode := inferAuthErrorCode(status, e.Title, e.Detail)
|
|
if strings.TrimSpace(e.Code) == "" {
|
|
e.Code = code
|
|
}
|
|
if strings.TrimSpace(e.ErrorCode) == "" {
|
|
e.ErrorCode = errorCode
|
|
}
|
|
}
|
|
enriched = append(enriched, e)
|
|
}
|
|
return response.WriteErrors(c, status, enriched)
|
|
}
|
|
|
|
func inferAuthErrorCode(status int, title, detail string) (string, string) {
|
|
lowerTitle := strings.ToLower(strings.TrimSpace(title))
|
|
lowerDetail := strings.ToLower(strings.TrimSpace(detail))
|
|
|
|
switch status {
|
|
case fiber.StatusAccepted:
|
|
if strings.Contains(lowerTitle, "pin verification") || strings.Contains(lowerDetail, "pin action token") {
|
|
return apperrorsx.CodePINVerificationRequired, apperrorsx.ErrPINVerificationRequired202.ErrorCode
|
|
}
|
|
return apperrorsx.CodeInvalidRequest, apperrorsx.ErrInvalidRequest.ErrorCode
|
|
case fiber.StatusUnauthorized:
|
|
if strings.Contains(lowerDetail, "token") || strings.Contains(lowerTitle, "invalid challenge") {
|
|
return apperrorsx.CodeTokenInvalid, apperrorsx.ErrTokenInvalid.ErrorCode
|
|
}
|
|
if strings.Contains(lowerDetail, "pin") {
|
|
return apperrorsx.CodePINIncorrect, apperrorsx.ErrPINIncorrect.ErrorCode
|
|
}
|
|
if strings.Contains(lowerDetail, "credentials") || strings.Contains(lowerTitle, "unauthorized") {
|
|
return apperrorsx.CodeUserUnauthorized, apperrorsx.ErrUserUnauthorized.ErrorCode
|
|
}
|
|
return apperrorsx.CodeUserUnauthorized, apperrorsx.ErrUserUnauthorized.ErrorCode
|
|
case fiber.StatusForbidden:
|
|
if strings.Contains(lowerTitle, "registration disabled") {
|
|
return apperrorsx.CodeAuthRegistrationDisabled, apperrorsx.ErrAuthRegistrationDisabled.ErrorCode
|
|
}
|
|
if strings.Contains(lowerDetail, "permission") || strings.Contains(lowerDetail, "forbidden") {
|
|
return apperrorsx.CodeForbiddenAccess, apperrorsx.ErrForbiddenAccess.ErrorCode
|
|
}
|
|
return apperrorsx.CodeForbiddenAccess, apperrorsx.ErrForbiddenAccess.ErrorCode
|
|
case fiber.StatusNotFound:
|
|
if strings.Contains(lowerDetail, "user not found") || strings.Contains(lowerDetail, "session is not found") {
|
|
return apperrorsx.CodeDataNotFound, apperrorsx.ErrDataNotFound.ErrorCode
|
|
}
|
|
return apperrorsx.CodeDataNotFound, apperrorsx.ErrDataNotFound.ErrorCode
|
|
case fiber.StatusConflict:
|
|
if strings.Contains(lowerDetail, "pin") {
|
|
return apperrorsx.CodePINNotSet, apperrorsx.ErrPINNotSet.ErrorCode
|
|
}
|
|
return apperrorsx.CodeBusinessRuleFailed, apperrorsx.ErrBusinessRuleFailed.ErrorCode
|
|
case fiber.StatusUnprocessableEntity:
|
|
if strings.Contains(lowerDetail, "pin") {
|
|
return apperrorsx.CodePINValidationFailed, apperrorsx.ErrPINValidationFailed.ErrorCode
|
|
}
|
|
if strings.Contains(lowerTitle, "validation") {
|
|
return apperrorsx.CodeValidationError, apperrorsx.ErrValidation.ErrorCode
|
|
}
|
|
return apperrorsx.CodeValidationError, apperrorsx.ErrValidation.ErrorCode
|
|
case fiber.StatusTooManyRequests:
|
|
if strings.Contains(lowerDetail, "blocked") || strings.Contains(lowerTitle, "pin blocked") {
|
|
return apperrorsx.CodePINBlocked, apperrorsx.ErrPINBlocked.ErrorCode
|
|
}
|
|
if strings.Contains(lowerDetail, "pin") || strings.Contains(lowerTitle, "too many attempts") {
|
|
return apperrorsx.CodePINAttemptLimitReached, apperrorsx.ErrPINAttemptLimitReached.ErrorCode
|
|
}
|
|
return apperrorsx.CodeBusinessRuleFailed, apperrorsx.ErrBusinessRuleFailed.ErrorCode
|
|
case fiber.StatusInternalServerError:
|
|
if strings.Contains(lowerTitle, "reset password failed") {
|
|
return apperrorsx.CodeAuthResetPasswordFailed, apperrorsx.ErrAuthResetPasswordFailed.ErrorCode
|
|
}
|
|
return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode
|
|
case fiber.StatusBadRequest:
|
|
if strings.Contains(lowerTitle, "invalid json") {
|
|
return apperrorsx.CodeAuthInvalidJSON, apperrorsx.ErrAuthInvalidJSON.ErrorCode
|
|
}
|
|
if strings.Contains(lowerTitle, "role not found") {
|
|
return apperrorsx.CodeAuthRoleNotFound, apperrorsx.ErrAuthRoleNotFound.ErrorCode
|
|
}
|
|
if strings.Contains(lowerTitle, "registration failed") {
|
|
if strings.Contains(lowerDetail, "already registered") {
|
|
return apperrorsx.CodeAuthUsernameAlreadyRegistered, apperrorsx.ErrAuthUsernameAlreadyRegistered.ErrorCode
|
|
}
|
|
return apperrorsx.CodeAuthRegistrationFailed, apperrorsx.ErrAuthRegistrationFailed.ErrorCode
|
|
}
|
|
if strings.Contains(lowerTitle, "invite failed") {
|
|
return apperrorsx.CodeAuthInviteFailed, apperrorsx.ErrAuthInviteFailed.ErrorCode
|
|
}
|
|
if strings.Contains(lowerTitle, "set password failed") {
|
|
if strings.Contains(lowerDetail, "invalid or expired token") {
|
|
return apperrorsx.CodeAuthSetPasswordTokenInvalid, apperrorsx.ErrAuthSetPasswordTokenInvalid.ErrorCode
|
|
}
|
|
return apperrorsx.CodeAuthSetPasswordFailed, apperrorsx.ErrAuthSetPasswordFailed.ErrorCode
|
|
}
|
|
if strings.Contains(lowerTitle, "invalid reset link") {
|
|
return apperrorsx.CodeAuthResetPasswordInvalidLink, apperrorsx.ErrAuthResetPasswordInvalidLink.ErrorCode
|
|
}
|
|
return apperrorsx.CodeInvalidRequest, apperrorsx.ErrInvalidRequest.ErrorCode
|
|
default:
|
|
return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode
|
|
}
|
|
}
|
|
|
|
func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) {
|
|
clearAuthCookieScopes(c, svc, parseSameSite(svc.CookieSameSite()), svc.CookieSecure())
|
|
}
|
|
|
|
func clearAuthCookiesForFrontChannel(c *fiber.Ctx, svc *service.AuthService) {
|
|
clearAuthCookieScopes(c, svc, "None", true)
|
|
}
|
|
|
|
func parseSameSite(v string) string {
|
|
switch strings.ToLower(v) {
|
|
case "strict":
|
|
return "Strict"
|
|
case "none":
|
|
return "None"
|
|
default:
|
|
return "Lax"
|
|
}
|
|
}
|
|
|
|
func readDeviceTypeHint(c *fiber.Ctx) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(strings.ToLower(c.Get(deviceTypeHeader)))
|
|
}
|
|
|
|
func authUserFoto(ctx context.Context, storage fileManagerDownloadURLStorage, u *auth.User) *dto.UserFoto {
|
|
if u == nil {
|
|
return nil
|
|
}
|
|
fotoUUID := uuidBytesToStringOrEmpty(u.ProfileAttachmentID)
|
|
fotoDownloadURL := ""
|
|
fotoThumbnailURL := ""
|
|
var fotoOriginalSizeBytes *int64
|
|
if u.ProfileAttachment != nil && u.ProfileAttachment.File != nil {
|
|
asset := resolveFileManagerDownloadURLs(ctx, storage, u.ProfileAttachment.File)
|
|
fotoDownloadURL = strings.TrimSpace(asset.OriginalURL)
|
|
fotoThumbnailURL = strings.TrimSpace(asset.ThumbnailURL)
|
|
fotoOriginalSizeBytes = int64Ptr(asset.OriginalSizeBytes)
|
|
}
|
|
if fotoUUID == "" && fotoDownloadURL == "" && fotoThumbnailURL == "" && fotoOriginalSizeBytes == nil {
|
|
return nil
|
|
}
|
|
return &dto.UserFoto{
|
|
UUID: fotoUUID,
|
|
DownloadURL: fotoDownloadURL,
|
|
ThumbnailDownloadURL: fotoThumbnailURL,
|
|
OriginalSizeBytes: fotoOriginalSizeBytes,
|
|
}
|
|
}
|