2025-08-19 12:49:05 +08:00
package api
2025-09-30 18:42:10 +08:00
import (
2025-10-02 14:06:07 +08:00
"context"
2025-09-30 18:42:10 +08:00
"crypto/rand"
2025-10-05 08:45:11 +08:00
"crypto/sha1"
"encoding/base32"
2025-09-30 18:42:10 +08:00
"encoding/hex"
"errors"
2025-10-02 17:56:30 +08:00
"fmt"
"html"
"log/slog"
2025-10-31 19:17:03 +08:00
"math/big"
2025-09-30 18:42:10 +08:00
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
2025-10-02 14:06:07 +08:00
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
2025-09-30 18:42:10 +08:00
"golang.org/x/crypto/bcrypt"
2025-12-06 10:32:31 +08:00
"account/internal/auth"
"account/internal/service"
"account/internal/store"
2025-09-30 18:42:10 +08:00
)
2025-10-01 10:05:31 +08:00
const defaultSessionTTL = 24 * time . Hour
2025-10-02 14:06:07 +08:00
const defaultMFAChallengeTTL = 10 * time . Minute
const defaultTOTPIssuer = "XControl Account"
2025-10-31 19:17:03 +08:00
const defaultEmailVerificationTTL = 10 * time . Minute
2025-10-02 17:56:30 +08:00
const defaultPasswordResetTTL = 30 * time . Minute
2025-10-05 08:45:11 +08:00
const maxMFAVerificationAttempts = 5
const defaultMFALockoutDuration = 5 * time . Minute
2025-09-30 18:42:10 +08:00
2025-10-06 15:17:35 +08:00
const sessionCookieName = "xc_session"
2025-09-30 18:42:10 +08:00
type session struct {
userID string
expiresAt time . Time
}
type handler struct {
2025-11-01 21:34:35 +08:00
store store . Store
sessions map [ string ] session
mu sync . RWMutex
sessionTTL time . Duration
mfaChallenges map [ string ] mfaChallenge
mfaMu sync . RWMutex
mfaChallengeTTL time . Duration
totpIssuer string
emailSender EmailSender
emailVerificationEnabled bool
verificationTTL time . Duration
verifications map [ string ] emailVerification
verificationMu sync . RWMutex
registrationVerifications map [ string ] registrationVerification
registrationMu sync . RWMutex
resetTTL time . Duration
passwordResets map [ string ] passwordReset
resetMu sync . RWMutex
metricsProvider service . UserMetricsProvider
agentStatusReader agentStatusReader
2025-11-05 20:11:23 +08:00
tokenService * auth . TokenService
2025-10-02 14:06:07 +08:00
}
type mfaChallenge struct {
2025-10-05 08:45:11 +08:00
userID string
expiresAt time . Time
totpSecret string
totpIssuer string
totpAccount string
totpIssuedAt time . Time
failedAttempts int
lockedUntil time . Time
2025-10-01 10:05:31 +08:00
}
2025-10-02 17:56:30 +08:00
type emailVerification struct {
userID string
email string
2025-10-31 19:17:03 +08:00
code string
2025-10-02 17:56:30 +08:00
expiresAt time . Time
}
type passwordReset struct {
userID string
email string
expiresAt time . Time
}
2025-11-01 21:34:35 +08:00
type registrationVerification struct {
email string
code string
expiresAt time . Time
verified bool
}
2025-10-01 10:05:31 +08:00
// Option configures handler behaviour when registering routes.
type Option func ( * handler )
// WithStore overrides the default in-memory store with the provided implementation.
func WithStore ( st store . Store ) Option {
return func ( h * handler ) {
if st != nil {
h . store = st
}
}
}
// WithSessionTTL sets the TTL used for issued sessions.
func WithSessionTTL ( ttl time . Duration ) Option {
return func ( h * handler ) {
if ttl > 0 {
h . sessionTTL = ttl
}
}
2025-09-30 18:42:10 +08:00
}
2025-08-19 12:49:05 +08:00
2025-10-02 17:56:30 +08:00
// WithEmailSender configures the handler to use the provided EmailSender for outbound notifications.
func WithEmailSender ( sender EmailSender ) Option {
return func ( h * handler ) {
if sender != nil {
h . emailSender = sender
}
}
}
2025-10-03 09:29:59 +08:00
// WithEmailVerification configures whether user registration requires email verification.
func WithEmailVerification ( enabled bool ) Option {
return func ( h * handler ) {
h . emailVerificationEnabled = enabled
}
}
2025-10-02 17:56:30 +08:00
// WithEmailVerificationTTL overrides the default TTL for email verification tokens.
func WithEmailVerificationTTL ( ttl time . Duration ) Option {
return func ( h * handler ) {
if ttl > 0 {
h . verificationTTL = ttl
}
}
}
2025-10-07 08:53:16 +08:00
// WithUserMetricsProvider configures the handler with the provided metrics provider.
func WithUserMetricsProvider ( provider service . UserMetricsProvider ) Option {
return func ( h * handler ) {
if provider != nil {
h . metricsProvider = provider
}
}
}
2025-10-27 21:02:03 +08:00
// WithAgentStatusReader wires the agent status reader used by admin endpoints.
func WithAgentStatusReader ( reader agentStatusReader ) Option {
return func ( h * handler ) {
if reader != nil {
h . agentStatusReader = reader
}
}
}
2025-10-02 17:56:30 +08:00
// WithPasswordResetTTL overrides the default TTL for password reset tokens.
func WithPasswordResetTTL ( ttl time . Duration ) Option {
return func ( h * handler ) {
if ttl > 0 {
h . resetTTL = ttl
}
}
}
2025-11-05 20:11:23 +08:00
// WithTokenService configures the handler with the provided token service.
func WithTokenService ( tokenService * auth . TokenService ) Option {
return func ( h * handler ) {
if tokenService != nil {
h . tokenService = tokenService
}
}
}
2025-08-19 12:49:05 +08:00
// RegisterRoutes attaches account service endpoints to the router.
2025-10-01 10:05:31 +08:00
func RegisterRoutes ( r * gin . Engine , opts ... Option ) {
2025-09-30 18:42:10 +08:00
h := & handler {
2025-11-01 21:34:35 +08:00
store : store . NewMemoryStore ( ) ,
sessions : make ( map [ string ] session ) ,
sessionTTL : defaultSessionTTL ,
mfaChallenges : make ( map [ string ] mfaChallenge ) ,
mfaChallengeTTL : defaultMFAChallengeTTL ,
totpIssuer : defaultTOTPIssuer ,
emailSender : noopEmailSender ,
emailVerificationEnabled : true ,
verificationTTL : defaultEmailVerificationTTL ,
verifications : make ( map [ string ] emailVerification ) ,
registrationVerifications : make ( map [ string ] registrationVerification ) ,
resetTTL : defaultPasswordResetTTL ,
passwordResets : make ( map [ string ] passwordReset ) ,
2025-10-01 10:05:31 +08:00
}
for _ , opt := range opts {
opt ( h )
2025-09-30 18:42:10 +08:00
}
2025-08-19 12:49:05 +08:00
r . GET ( "/healthz" , func ( c * gin . Context ) {
2025-09-30 18:42:10 +08:00
c . JSON ( http . StatusOK , gin . H { "status" : "ok" } )
} )
2025-10-01 00:09:13 +08:00
auth := r . Group ( "/api/auth" )
2025-10-07 09:41:27 +08:00
2025-10-01 00:09:13 +08:00
auth . POST ( "/register" , h . register )
2025-10-02 17:56:30 +08:00
auth . POST ( "/register/verify" , h . verifyEmail )
2025-11-01 20:25:37 +08:00
auth . POST ( "/register/send" , h . sendEmailVerification )
2025-10-07 09:41:27 +08:00
2025-10-01 00:09:13 +08:00
auth . POST ( "/login" , h . login )
2025-10-07 09:41:27 +08:00
2025-11-05 20:11:23 +08:00
// Token exchange endpoint - converts public token to access/refresh tokens
auth . POST ( "/token/exchange" , h . exchangeToken )
// Token refresh endpoint - generates new access token using refresh token
auth . POST ( "/token/refresh" , h . refreshToken )
2025-10-07 09:41:27 +08:00
2025-11-05 20:11:23 +08:00
// Protected routes requiring authentication
authProtected := auth . Group ( "" )
if h . tokenService != nil {
authProtected . Use ( h . tokenService . AuthMiddleware ( ) )
}
2025-10-07 09:41:27 +08:00
2025-11-05 20:11:23 +08:00
authProtected . GET ( "/session" , h . session )
authProtected . DELETE ( "/session" , h . deleteSession )
2025-10-28 09:47:30 +08:00
2025-11-05 20:11:23 +08:00
authProtected . POST ( "/mfa/totp/provision" , h . provisionTOTP )
authProtected . POST ( "/mfa/totp/verify" , h . verifyTOTP )
authProtected . POST ( "/mfa/disable" , h . disableMFA )
authProtected . GET ( "/mfa/status" , h . mfaStatus )
2025-10-07 09:41:27 +08:00
2025-11-05 20:11:23 +08:00
authProtected . POST ( "/password/reset" , h . requestPasswordReset )
authProtected . POST ( "/password/reset/confirm" , h . confirmPasswordReset )
2025-10-07 09:41:27 +08:00
2025-11-21 18:55:12 +08:00
authProtected . GET ( "/subscriptions" , h . listSubscriptions )
authProtected . POST ( "/subscriptions" , h . upsertSubscription )
authProtected . POST ( "/subscriptions/cancel" , h . cancelSubscription )
2025-11-05 20:11:23 +08:00
authProtected . POST ( "/config/sync" , h . syncConfig )
authProtected . GET ( "/admin/settings" , h . getAdminSettings )
authProtected . POST ( "/admin/settings" , h . updateAdminSettings )
registerAdminRoutes ( authProtected , h )
2025-09-30 18:42:10 +08:00
}
type registerRequest struct {
2025-10-02 09:43:22 +08:00
Name string ` json:"name" `
Email string ` json:"email" `
Password string ` json:"password" `
2025-11-01 21:34:35 +08:00
Code string ` json:"code" `
2025-10-01 10:39:15 +08:00
}
type loginRequest struct {
2025-10-02 14:06:07 +08:00
Identifier string ` json:"identifier" `
Username string ` json:"username" `
Email string ` json:"email" `
Password string ` json:"password" `
TOTPCode string ` json:"totpCode" `
2025-10-02 09:43:22 +08:00
}
2025-10-31 19:17:03 +08:00
type verificationCodeRequest struct {
Email string ` json:"email" `
Code string ` json:"code" `
}
2025-11-01 20:25:37 +08:00
type verificationSendRequest struct {
2025-10-31 19:17:03 +08:00
Email string ` json:"email" `
2025-10-02 17:56:30 +08:00
}
type passwordResetRequestBody struct {
Email string ` json:"email" `
}
type passwordResetConfirmRequest struct {
Token string ` json:"token" `
Password string ` json:"password" `
}
2025-11-21 18:55:12 +08:00
type subscriptionUpsertRequest struct {
2025-11-21 19:20:51 +08:00
ExternalID string ` json:"externalId" `
Provider string ` json:"provider" `
PaymentMethod string ` json:"paymentMethod" `
PaymentQRCode string ` json:"paymentQr" `
Kind string ` json:"kind" `
PlanID string ` json:"planId" `
Status string ` json:"status" `
Meta map [ string ] any ` json:"meta" `
2025-11-21 18:55:12 +08:00
}
type subscriptionCancelRequest struct {
ExternalID string ` json:"externalId" `
}
2025-10-02 09:43:22 +08:00
func hasQueryParameter ( c * gin . Context , keys ... string ) bool {
if len ( keys ) == 0 {
return false
}
values := c . Request . URL . Query ( )
for _ , key := range keys {
if _ , ok := values [ key ] ; ok {
return true
}
}
return false
2025-09-30 18:42:10 +08:00
}
func ( h * handler ) register ( c * gin . Context ) {
2025-10-02 08:40:59 +08:00
if hasQueryParameter ( c , "password" , "email" , "confirmPassword" ) {
respondError ( c , http . StatusBadRequest , "credentials_in_query" , "sensitive credentials must not be sent in the query string" )
return
}
2025-09-30 18:42:10 +08:00
var req registerRequest
2025-10-02 09:43:22 +08:00
if err := c . ShouldBindJSON ( & req ) ; err != nil {
2025-10-01 07:52:39 +08:00
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
2025-09-30 18:42:10 +08:00
return
}
name := strings . TrimSpace ( req . Name )
email := strings . ToLower ( strings . TrimSpace ( req . Email ) )
password := strings . TrimSpace ( req . Password )
2025-11-01 21:34:35 +08:00
code := strings . TrimSpace ( req . Code )
2025-09-30 18:42:10 +08:00
2025-10-01 07:52:39 +08:00
if name == "" {
respondError ( c , http . StatusBadRequest , "name_required" , "name is required" )
return
}
2025-09-30 18:42:10 +08:00
if email == "" || password == "" {
2025-10-01 07:52:39 +08:00
respondError ( c , http . StatusBadRequest , "missing_credentials" , "email and password are required" )
2025-09-30 18:42:10 +08:00
return
}
if ! strings . Contains ( email , "@" ) {
2025-10-01 07:52:39 +08:00
respondError ( c , http . StatusBadRequest , "invalid_email" , "email must be a valid address" )
2025-09-30 18:42:10 +08:00
return
}
if len ( password ) < 8 {
2025-10-01 07:52:39 +08:00
respondError ( c , http . StatusBadRequest , "password_too_short" , "password must be at least 8 characters" )
2025-09-30 18:42:10 +08:00
return
}
2025-11-01 21:34:35 +08:00
if h . emailVerificationEnabled {
if code == "" {
respondError ( c , http . StatusBadRequest , "verification_required" , "verification code is required" )
return
}
verification , ok := h . lookupRegistrationVerification ( email )
if ! ok {
respondError ( c , http . StatusBadRequest , "verification_required" , "verification code is required" )
return
}
if ! verification . verified {
respondError ( c , http . StatusBadRequest , "verification_required" , "verification code is required" )
return
}
if verification . code != code {
respondError ( c , http . StatusBadRequest , "invalid_code" , "verification code is invalid or expired" )
return
}
}
2025-09-30 18:42:10 +08:00
hashed , err := bcrypt . GenerateFromPassword ( [ ] byte ( password ) , bcrypt . DefaultCost )
if err != nil {
2025-10-01 07:52:39 +08:00
respondError ( c , http . StatusInternalServerError , "hash_failure" , "failed to secure password" )
2025-09-30 18:42:10 +08:00
return
}
user := & store . User {
Name : name ,
Email : email ,
PasswordHash : string ( hashed ) ,
2025-10-07 08:23:53 +08:00
Level : store . LevelUser ,
Role : store . RoleUser ,
Groups : [ ] string { "User" } ,
2025-09-30 18:42:10 +08:00
}
2025-11-01 21:34:35 +08:00
if ! h . emailVerificationEnabled || code != "" {
2025-10-03 09:29:59 +08:00
user . EmailVerified = true
}
2025-09-30 18:42:10 +08:00
if err := h . store . CreateUser ( c . Request . Context ( ) , user ) ; err != nil {
2025-10-01 07:52:39 +08:00
switch {
case errors . Is ( err , store . ErrEmailExists ) :
respondError ( c , http . StatusConflict , "email_already_exists" , "user with this email already exists" )
return
case errors . Is ( err , store . ErrNameExists ) :
respondError ( c , http . StatusConflict , "name_already_exists" , "user with this name already exists" )
return
case errors . Is ( err , store . ErrInvalidName ) :
respondError ( c , http . StatusBadRequest , "invalid_name" , "name is invalid" )
return
default :
respondError ( c , http . StatusInternalServerError , "user_creation_failed" , "failed to create user" )
2025-09-30 18:42:10 +08:00
return
}
}
2025-10-03 09:29:59 +08:00
if h . emailVerificationEnabled {
2025-11-01 21:34:35 +08:00
h . removeRegistrationVerification ( email )
2025-10-02 17:56:30 +08:00
}
2025-11-21 19:20:51 +08:00
trialExpiresAt := time . Now ( ) . UTC ( ) . Add ( 7 * 24 * time . Hour )
trial := & store . Subscription {
UserID : user . ID ,
Provider : "trial" ,
PaymentMethod : "trial" ,
Kind : "trial" ,
PlanID : "TRIAL-7D" ,
ExternalID : fmt . Sprintf ( "trial-%s" , user . ID ) ,
Status : "active" ,
Meta : map [ string ] any {
"startsAt" : time . Now ( ) . UTC ( ) ,
"expiresAt" : trialExpiresAt ,
"note" : "new user full-access trial" ,
} ,
}
if err := h . store . UpsertSubscription ( c . Request . Context ( ) , trial ) ; err != nil {
slog . Warn ( "failed to provision onboarding trial" , "err" , err , "userID" , user . ID )
}
2025-11-01 21:34:35 +08:00
message := "registration successful"
2025-10-01 07:52:39 +08:00
response := gin . H {
2025-10-03 09:29:59 +08:00
"message" : message ,
2025-10-05 08:45:11 +08:00
"user" : sanitizeUser ( user , nil ) ,
2025-10-01 07:52:39 +08:00
}
2025-09-30 18:42:10 +08:00
c . JSON ( http . StatusCreated , response )
}
2025-10-02 17:56:30 +08:00
func ( h * handler ) verifyEmail ( c * gin . Context ) {
2025-10-31 19:17:03 +08:00
if hasQueryParameter ( c , "token" , "code" ) {
respondError ( c , http . StatusBadRequest , "token_in_query" , "verification code must be sent in the request body" )
2025-10-02 17:56:30 +08:00
return
}
2025-10-31 19:17:03 +08:00
var req verificationCodeRequest
2025-10-02 17:56:30 +08:00
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
2025-10-31 19:17:03 +08:00
email := strings . ToLower ( strings . TrimSpace ( req . Email ) )
code := strings . TrimSpace ( req . Code )
if email == "" || code == "" {
respondError ( c , http . StatusBadRequest , "invalid_request" , "email and verification code are required" )
return
}
if len ( code ) != 6 {
respondError ( c , http . StatusBadRequest , "invalid_code" , "verification code must be 6 digits" )
2025-10-02 17:56:30 +08:00
return
}
2025-10-31 19:17:03 +08:00
for _ , r := range code {
if r < '0' || r > '9' {
respondError ( c , http . StatusBadRequest , "invalid_code" , "verification code must be 6 digits" )
return
}
}
2025-11-01 21:34:35 +08:00
if verification , ok := h . lookupEmailVerification ( email ) ; ok {
if verification . code != code {
respondError ( c , http . StatusBadRequest , "invalid_code" , "verification code is invalid or expired" )
return
}
2025-10-31 19:17:03 +08:00
2025-11-01 21:34:35 +08:00
user , err := h . store . GetUserByID ( c . Request . Context ( ) , verification . userID )
if err != nil {
slog . Error ( "failed to load user for email verification" , "err" , err , "userID" , verification . userID )
respondError ( c , http . StatusInternalServerError , "verification_failed" , "failed to verify email" )
return
}
2025-10-02 17:56:30 +08:00
2025-11-01 21:34:35 +08:00
if ! strings . EqualFold ( strings . TrimSpace ( user . Email ) , verification . email ) {
h . removeEmailVerification ( email )
respondError ( c , http . StatusBadRequest , "invalid_code" , "verification code is invalid or expired" )
return
}
if ! user . EmailVerified {
user . EmailVerified = true
if err := h . store . UpdateUser ( c . Request . Context ( ) , user ) ; err != nil {
slog . Error ( "failed to update user during email verification" , "err" , err , "userID" , user . ID )
respondError ( c , http . StatusInternalServerError , "verification_failed" , "failed to verify email" )
return
}
}
2025-10-02 17:56:30 +08:00
2025-10-31 19:17:03 +08:00
h . removeEmailVerification ( email )
2025-10-02 17:56:30 +08:00
2025-11-01 21:34:35 +08:00
sessionToken , expiresAt , err := h . createSession ( user . ID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "session_creation_failed" , "failed to create session" )
2025-10-02 17:56:30 +08:00
return
}
2025-11-01 21:34:35 +08:00
h . setSessionCookie ( c , sessionToken , expiresAt )
2025-10-02 17:56:30 +08:00
2025-11-01 21:34:35 +08:00
c . JSON ( http . StatusOK , gin . H {
"message" : "email verified" ,
"token" : sessionToken ,
"expiresAt" : expiresAt . UTC ( ) ,
"user" : sanitizeUser ( user , nil ) ,
} )
2025-10-02 17:56:30 +08:00
return
}
2025-11-01 21:34:35 +08:00
pending , ok := h . lookupRegistrationVerification ( email )
if ! ok || pending . code != code {
respondError ( c , http . StatusBadRequest , "invalid_code" , "verification code is invalid or expired" )
return
}
2025-10-06 15:43:25 +08:00
2025-11-01 21:34:35 +08:00
if ! h . markRegistrationVerified ( email ) {
respondError ( c , http . StatusBadRequest , "invalid_code" , "verification code is invalid or expired" )
return
}
c . JSON ( http . StatusOK , gin . H { "message" : "verification successful" , "verified" : true } )
2025-10-02 17:56:30 +08:00
}
2025-11-01 20:25:37 +08:00
func ( h * handler ) sendEmailVerification ( c * gin . Context ) {
2025-10-31 19:17:03 +08:00
if hasQueryParameter ( c , "email" ) {
respondError ( c , http . StatusBadRequest , "email_in_query" , "email must be sent in the request body" )
return
}
2025-11-01 20:25:37 +08:00
var req verificationSendRequest
2025-10-31 19:17:03 +08:00
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
email := strings . ToLower ( strings . TrimSpace ( req . Email ) )
if email == "" {
respondError ( c , http . StatusBadRequest , "invalid_email" , "email must be a valid address" )
return
}
2025-11-04 09:38:15 +08:00
// 与线上 SMTP 配置对齐:统一使用 10s 的超时控制
ctx , cancel := context . WithTimeout ( c . Request . Context ( ) , 10 * time . Second )
defer cancel ( )
// 基础邮箱校验,避免明显无效地址触发外发
if ! strings . Contains ( email , "@" ) {
respondError ( c , http . StatusBadRequest , "invalid_email" , "email must be a valid address" )
return
}
user , err := h . store . GetUserByEmail ( ctx , email )
2025-11-01 21:34:35 +08:00
if err == nil {
if strings . TrimSpace ( user . Email ) == "" {
respondError ( c , http . StatusBadRequest , "invalid_email" , "email must be a valid address" )
2025-10-31 19:17:03 +08:00
return
}
2025-11-01 21:34:35 +08:00
if user . EmailVerified {
respondError ( c , http . StatusConflict , "email_already_exists" , "email is already registered" )
return
}
2025-11-04 09:38:15 +08:00
if err := h . enqueueEmailVerification ( ctx , user ) ; err != nil {
2025-11-01 21:34:35 +08:00
slog . Error ( "failed to send verification email" , "err" , err , "email" , user . Email )
2025-11-04 09:38:15 +08:00
if errors . Is ( err , context . DeadlineExceeded ) || errors . Is ( err , context . Canceled ) {
respondError ( c , http . StatusGatewayTimeout , "smtp_timeout" , "email sending timed out" )
} else {
respondError ( c , http . StatusInternalServerError , "verification_failed" , "verification email could not be sent" )
}
2025-11-01 21:34:35 +08:00
return
}
c . JSON ( http . StatusOK , gin . H { "message" : "verification email sent" } )
2025-10-31 19:17:03 +08:00
return
}
2025-11-01 21:34:35 +08:00
if err != nil && ! errors . Is ( err , store . ErrUserNotFound ) {
respondError ( c , http . StatusInternalServerError , "verification_failed" , "verification email could not be sent" )
2025-10-31 19:17:03 +08:00
return
}
2025-11-04 09:38:15 +08:00
if _ , err := h . issueRegistrationVerification ( ctx , email ) ; err != nil {
2025-11-01 21:34:35 +08:00
slog . Error ( "failed to issue registration verification" , "err" , err , "email" , email )
2025-11-04 09:38:15 +08:00
if errors . Is ( err , context . DeadlineExceeded ) || errors . Is ( err , context . Canceled ) {
respondError ( c , http . StatusGatewayTimeout , "smtp_timeout" , "email sending timed out" )
} else {
respondError ( c , http . StatusInternalServerError , "verification_failed" , "verification email could not be sent" )
}
2025-10-31 19:17:03 +08:00
return
}
2025-11-01 20:25:37 +08:00
c . JSON ( http . StatusOK , gin . H { "message" : "verification email sent" } )
2025-10-31 19:17:03 +08:00
}
2025-10-02 17:56:30 +08:00
func ( h * handler ) requestPasswordReset ( c * gin . Context ) {
if hasQueryParameter ( c , "email" ) {
respondError ( c , http . StatusBadRequest , "email_in_query" , "email must be sent in the request body" )
return
}
var req passwordResetRequestBody
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
email := strings . ToLower ( strings . TrimSpace ( req . Email ) )
if email == "" {
respondError ( c , http . StatusBadRequest , "email_required" , "email is required" )
return
}
user , err := h . store . GetUserByEmail ( c . Request . Context ( ) , email )
if err != nil {
if errors . Is ( err , store . ErrUserNotFound ) {
c . JSON ( http . StatusAccepted , gin . H { "message" : "if the account exists a reset email will be sent" } )
return
}
respondError ( c , http . StatusInternalServerError , "password_reset_failed" , "failed to initiate password reset" )
return
}
if strings . TrimSpace ( user . Email ) == "" || ! user . EmailVerified {
c . JSON ( http . StatusAccepted , gin . H { "message" : "if the account exists a reset email will be sent" } )
return
}
if err := h . enqueuePasswordReset ( c . Request . Context ( ) , user ) ; err != nil {
slog . Error ( "failed to send password reset email" , "err" , err , "email" , user . Email )
respondError ( c , http . StatusInternalServerError , "password_reset_failed" , "failed to initiate password reset" )
return
}
c . JSON ( http . StatusAccepted , gin . H { "message" : "if the account exists a reset email will be sent" } )
}
func ( h * handler ) confirmPasswordReset ( c * gin . Context ) {
if hasQueryParameter ( c , "token" , "password" ) {
respondError ( c , http . StatusBadRequest , "credentials_in_query" , "sensitive credentials must not be sent in the query string" )
return
}
var req passwordResetConfirmRequest
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
token := strings . TrimSpace ( req . Token )
password := strings . TrimSpace ( req . Password )
if token == "" || password == "" {
respondError ( c , http . StatusBadRequest , "invalid_request" , "token and password are required" )
return
}
if len ( password ) < 8 {
respondError ( c , http . StatusBadRequest , "password_too_short" , "password must be at least 8 characters" )
return
}
reset , ok := h . lookupPasswordReset ( token )
if ! ok {
respondError ( c , http . StatusBadRequest , "invalid_token" , "reset token is invalid or expired" )
return
}
user , err := h . store . GetUserByID ( c . Request . Context ( ) , reset . userID )
if err != nil {
slog . Error ( "failed to load user for password reset" , "err" , err , "userID" , reset . userID )
respondError ( c , http . StatusInternalServerError , "password_reset_failed" , "failed to reset password" )
return
}
if ! strings . EqualFold ( strings . TrimSpace ( user . Email ) , reset . email ) {
h . removePasswordReset ( token )
respondError ( c , http . StatusBadRequest , "invalid_token" , "reset token is invalid or expired" )
return
}
hashed , err := bcrypt . GenerateFromPassword ( [ ] byte ( password ) , bcrypt . DefaultCost )
if err != nil {
respondError ( c , http . StatusInternalServerError , "password_reset_failed" , "failed to reset password" )
return
}
user . PasswordHash = string ( hashed )
user . EmailVerified = true
if err := h . store . UpdateUser ( c . Request . Context ( ) , user ) ; err != nil {
slog . Error ( "failed to update user during password reset" , "err" , err , "userID" , user . ID )
respondError ( c , http . StatusInternalServerError , "password_reset_failed" , "failed to reset password" )
return
}
h . removePasswordReset ( token )
sessionToken , expiresAt , err := h . createSession ( user . ID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "session_creation_failed" , "failed to create session" )
return
}
2025-10-06 15:43:25 +08:00
h . setSessionCookie ( c , sessionToken , expiresAt )
2025-10-02 17:56:30 +08:00
c . JSON ( http . StatusOK , gin . H {
"message" : "password reset successful" ,
"token" : sessionToken ,
"expiresAt" : expiresAt . UTC ( ) ,
2025-10-05 08:45:11 +08:00
"user" : sanitizeUser ( user , nil ) ,
2025-10-02 17:56:30 +08:00
} )
}
2025-10-07 09:24:00 +08:00
var allowedAdminRoles = map [ string ] struct { } {
"admin" : { } ,
"operator" : { } ,
"user" : { } ,
}
func ( h * handler ) getAdminSettings ( c * gin . Context ) {
2025-10-07 09:41:27 +08:00
if _ , ok := h . requireAdminOrOperator ( c ) ; ! ok {
2025-10-07 09:24:00 +08:00
return
}
settings , err := service . GetAdminSettings ( c . Request . Context ( ) )
if err != nil {
status := http . StatusInternalServerError
if errors . Is ( err , service . ErrServiceDBNotInitialized ) {
status = http . StatusServiceUnavailable
}
c . JSON ( status , gin . H { "error" : err . Error ( ) } )
return
}
c . JSON ( http . StatusOK , gin . H {
"version" : settings . Version ,
"matrix" : settings . Matrix ,
} )
}
func ( h * handler ) updateAdminSettings ( c * gin . Context ) {
2025-10-07 09:41:27 +08:00
if _ , ok := h . requireAdminOrOperator ( c ) ; ! ok {
2025-10-07 09:24:00 +08:00
return
}
2025-10-27 11:41:05 +08:00
var req struct {
Version uint64 ` json:"version" `
Matrix map [ string ] map [ string ] bool ` json:"matrix" `
}
2025-10-07 09:24:00 +08:00
if err := c . ShouldBindJSON ( & req ) ; err != nil {
c . JSON ( http . StatusBadRequest , gin . H { "error" : err . Error ( ) } )
return
}
normalized , err := normalizeAdminMatrix ( req . Matrix )
if err != nil {
c . JSON ( http . StatusBadRequest , gin . H { "error" : err . Error ( ) } )
return
}
updated , err := service . SaveAdminSettings ( c . Request . Context ( ) , service . AdminSettings {
Version : req . Version ,
Matrix : normalized ,
} )
if err != nil {
if errors . Is ( err , service . ErrAdminSettingsVersionConflict ) {
c . JSON ( http . StatusConflict , gin . H {
"error" : err . Error ( ) ,
"version" : updated . Version ,
"matrix" : updated . Matrix ,
} )
return
}
status := http . StatusInternalServerError
if errors . Is ( err , service . ErrServiceDBNotInitialized ) {
status = http . StatusServiceUnavailable
}
c . JSON ( status , gin . H { "error" : err . Error ( ) } )
return
}
c . JSON ( http . StatusOK , gin . H {
"version" : updated . Version ,
"matrix" : updated . Matrix ,
} )
}
func normalizeAdminMatrix ( in map [ string ] map [ string ] bool ) ( map [ string ] map [ string ] bool , error ) {
if in == nil {
return make ( map [ string ] map [ string ] bool ) , nil
}
out := make ( map [ string ] map [ string ] bool , len ( in ) )
for module , roles := range in {
moduleKey := strings . TrimSpace ( module )
if moduleKey == "" {
return nil , errors . New ( "module key cannot be empty" )
}
if roles == nil {
out [ moduleKey ] = make ( map [ string ] bool )
continue
}
normalizedRoles := make ( map [ string ] bool , len ( roles ) )
for role , enabled := range roles {
key := strings . ToLower ( strings . TrimSpace ( role ) )
if key == "" {
return nil , errors . New ( "role cannot be empty" )
}
if _ , ok := allowedAdminRoles [ key ] ; ! ok {
return nil , fmt . Errorf ( "unsupported role: %s" , role )
}
normalizedRoles [ key ] = enabled
}
out [ moduleKey ] = normalizedRoles
}
return out , nil
}
2025-09-30 18:42:10 +08:00
func ( h * handler ) login ( c * gin . Context ) {
2025-10-02 14:06:07 +08:00
if hasQueryParameter ( c , "username" , "password" , "identifier" , "totp" ) {
2025-10-02 08:40:59 +08:00
respondError ( c , http . StatusBadRequest , "credentials_in_query" , "sensitive credentials must not be sent in the query string" )
return
}
2025-10-01 10:39:15 +08:00
var req loginRequest
2025-10-02 09:43:22 +08:00
if err := c . ShouldBindJSON ( & req ) ; err != nil {
2025-10-01 08:24:37 +08:00
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
2025-09-30 18:42:10 +08:00
return
}
2025-10-02 14:06:07 +08:00
identifier := strings . TrimSpace ( req . Identifier )
if identifier == "" {
identifier = strings . TrimSpace ( req . Username )
}
if identifier == "" {
identifier = strings . TrimSpace ( req . Email )
}
2025-09-30 18:42:10 +08:00
password := strings . TrimSpace ( req . Password )
2025-10-02 14:06:07 +08:00
totpCode := strings . TrimSpace ( req . TOTPCode )
if identifier == "" {
respondError ( c , http . StatusBadRequest , "missing_credentials" , "identifier is required" )
2025-09-30 18:42:10 +08:00
return
}
2025-10-02 14:06:07 +08:00
user , err := h . findUserByIdentifier ( c . Request . Context ( ) , identifier )
2025-09-30 18:42:10 +08:00
if err != nil {
if errors . Is ( err , store . ErrUserNotFound ) {
2025-10-01 08:24:37 +08:00
respondError ( c , http . StatusNotFound , "user_not_found" , "user not found" )
2025-09-30 18:42:10 +08:00
return
}
2025-10-01 08:24:37 +08:00
respondError ( c , http . StatusInternalServerError , "authentication_failed" , "failed to authenticate user" )
2025-09-30 18:42:10 +08:00
return
}
2025-10-02 14:06:07 +08:00
if password != "" {
if bcrypt . CompareHashAndPassword ( [ ] byte ( user . PasswordHash ) , [ ] byte ( password ) ) != nil {
respondError ( c , http . StatusUnauthorized , "invalid_credentials" , "invalid credentials" )
return
}
} else {
if totpCode == "" {
respondError ( c , http . StatusBadRequest , "missing_credentials" , "totp code is required" )
return
}
if ! strings . EqualFold ( strings . TrimSpace ( user . Email ) , identifier ) {
respondError ( c , http . StatusUnauthorized , "password_required" , "password required for this identifier" )
return
}
}
2025-10-02 17:56:30 +08:00
if strings . TrimSpace ( user . Email ) != "" && ! user . EmailVerified {
respondError ( c , http . StatusUnauthorized , "email_not_verified" , "email must be verified before login" )
return
}
2025-10-05 08:00:16 +08:00
if user . MFAEnabled {
if totpCode == "" {
respondError ( c , http . StatusBadRequest , "mfa_code_required" , "totp code is required" )
2025-10-02 14:06:07 +08:00
return
}
2025-10-05 08:00:16 +08:00
valid , err := totp . ValidateCustom ( totpCode , user . MFATOTPSecret , time . Now ( ) . UTC ( ) , totp . ValidateOpts {
Period : 30 ,
Skew : 1 ,
Digits : otp . DigitsSix ,
Algorithm : otp . AlgorithmSHA1 ,
2025-10-02 14:06:07 +08:00
} )
2025-10-05 08:00:16 +08:00
if err != nil {
respondError ( c , http . StatusInternalServerError , "invalid_mfa_code" , "invalid totp code" )
return
}
if ! valid {
respondError ( c , http . StatusUnauthorized , "invalid_mfa_code" , "invalid totp code" )
return
}
2025-10-02 14:06:07 +08:00
2025-10-05 08:00:16 +08:00
token , expiresAt , err := h . createSession ( user . ID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "session_creation_failed" , "failed to create session" )
return
}
2025-10-02 14:06:07 +08:00
2025-10-06 15:43:25 +08:00
h . setSessionCookie ( c , token , expiresAt )
2025-10-05 08:00:16 +08:00
c . JSON ( http . StatusOK , gin . H {
"message" : "login successful" ,
"token" : token ,
"expiresAt" : expiresAt . UTC ( ) ,
2025-10-05 08:45:11 +08:00
"user" : sanitizeUser ( user , nil ) ,
2025-10-05 08:00:16 +08:00
} )
2025-09-30 18:42:10 +08:00
return
}
token , expiresAt , err := h . createSession ( user . ID )
if err != nil {
2025-10-01 08:24:37 +08:00
respondError ( c , http . StatusInternalServerError , "session_creation_failed" , "failed to create session" )
2025-09-30 18:42:10 +08:00
return
}
2025-10-06 15:43:25 +08:00
h . setSessionCookie ( c , token , expiresAt )
2025-10-05 08:00:16 +08:00
response := gin . H {
2025-10-01 08:24:37 +08:00
"message" : "login successful" ,
2025-09-30 18:42:10 +08:00
"token" : token ,
"expiresAt" : expiresAt . UTC ( ) ,
2025-10-05 08:45:11 +08:00
"user" : sanitizeUser ( user , nil ) ,
2025-10-05 08:00:16 +08:00
}
if challengeToken , err := h . createMFAChallenge ( user . ID ) ; err != nil {
slog . Error ( "failed to create mfa challenge during login" , "err" , err , "userID" , user . ID )
} else {
response [ "mfaToken" ] = challengeToken
}
c . JSON ( http . StatusOK , response )
2025-08-19 12:49:05 +08:00
}
2025-09-30 18:42:10 +08:00
2025-11-05 20:11:23 +08:00
type tokenRefreshRequest struct {
RefreshToken string ` json:"refresh_token" `
}
func ( h * handler ) refreshToken ( c * gin . Context ) {
if h . tokenService == nil {
respondError ( c , http . StatusServiceUnavailable , "token_service_unavailable" , "token service is not configured" )
return
}
var req tokenRefreshRequest
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
// Refresh access token
accessToken , err := h . tokenService . RefreshAccessToken ( req . RefreshToken )
if err != nil {
respondError ( c , http . StatusUnauthorized , "invalid_refresh_token" , "invalid or expired refresh token" )
return
}
c . JSON ( http . StatusOK , gin . H {
"access_token" : accessToken ,
"token_type" : "Bearer" ,
"expires_in" : int64 ( h . tokenService . GetAccessTokenExpiry ( ) . Seconds ( ) ) ,
} )
}
type tokenExchangeRequest struct {
PublicToken string ` json:"public_token" `
UserID string ` json:"user_id" `
Email string ` json:"email" `
Roles string ` json:"roles" `
}
func ( h * handler ) exchangeToken ( c * gin . Context ) {
if h . tokenService == nil {
respondError ( c , http . StatusServiceUnavailable , "token_service_unavailable" , "token service is not configured" )
return
}
var req tokenExchangeRequest
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
// Validate public token
if ! h . tokenService . ValidatePublicToken ( req . PublicToken ) {
respondError ( c , http . StatusUnauthorized , "invalid_public_token" , "invalid public token" )
return
}
// Parse roles
var roles [ ] string
if req . Roles != "" {
roles = strings . Split ( req . Roles , "," )
for i := range roles {
roles [ i ] = strings . TrimSpace ( roles [ i ] )
}
} else {
roles = [ ] string { "user" }
}
// Generate token pair
tokenPair , err := h . tokenService . GenerateTokenPair ( req . UserID , req . Email , roles )
if err != nil {
slog . Error ( "failed to generate token pair" , "err" , err )
respondError ( c , http . StatusInternalServerError , "token_generation_failed" , "failed to generate tokens" )
return
}
c . JSON ( http . StatusOK , gin . H {
"public_token" : tokenPair . PublicToken ,
"access_token" : tokenPair . AccessToken ,
"refresh_token" : tokenPair . RefreshToken ,
"token_type" : tokenPair . TokenType ,
"expires_in" : tokenPair . ExpiresIn ,
} )
}
2025-10-02 14:06:07 +08:00
func ( h * handler ) findUserByIdentifier ( ctx context . Context , identifier string ) ( * store . User , error ) {
user , err := h . store . GetUserByName ( ctx , identifier )
if err == nil {
return user , nil
}
if err != nil && ! errors . Is ( err , store . ErrUserNotFound ) {
return nil , err
}
return h . store . GetUserByEmail ( ctx , identifier )
}
2025-09-30 18:42:10 +08:00
func ( h * handler ) session ( c * gin . Context ) {
token := extractToken ( c . GetHeader ( "Authorization" ) )
if token == "" {
if value := c . Query ( "token" ) ; value != "" {
token = value
}
}
2025-10-06 15:17:35 +08:00
if token == "" {
if cookie , err := c . Cookie ( sessionCookieName ) ; err == nil {
cookie = strings . TrimSpace ( cookie )
if cookie != "" {
token = cookie
}
}
}
2025-09-30 18:42:10 +08:00
if token == "" {
c . JSON ( http . StatusUnauthorized , gin . H { "error" : "session token required" } )
return
}
sess , ok := h . lookupSession ( token )
if ! ok {
c . JSON ( http . StatusUnauthorized , gin . H { "error" : "session not found" } )
return
}
user , err := h . store . GetUserByID ( c . Request . Context ( ) , sess . userID )
if err != nil {
c . JSON ( http . StatusInternalServerError , gin . H { "error" : "failed to load session user" } )
return
}
2025-10-05 08:45:11 +08:00
c . JSON ( http . StatusOK , gin . H { "user" : sanitizeUser ( user , nil ) } )
2025-09-30 18:42:10 +08:00
}
func ( h * handler ) deleteSession ( c * gin . Context ) {
token := extractToken ( c . GetHeader ( "Authorization" ) )
if token == "" {
if value := c . Query ( "token" ) ; value != "" {
token = value
}
}
2025-10-06 15:17:35 +08:00
if token == "" {
if cookie , err := c . Cookie ( sessionCookieName ) ; err == nil {
cookie = strings . TrimSpace ( cookie )
if cookie != "" {
token = cookie
}
}
}
2025-09-30 18:42:10 +08:00
if token == "" {
c . Status ( http . StatusNoContent )
return
}
h . removeSession ( token )
c . Status ( http . StatusNoContent )
}
2025-11-21 18:55:12 +08:00
func ( h * handler ) requireAuthenticatedUser ( c * gin . Context ) ( * store . User , bool ) {
token := extractToken ( c . GetHeader ( "Authorization" ) )
if token == "" {
if value := c . Query ( "token" ) ; value != "" {
token = value
}
}
if token == "" {
if cookie , err := c . Cookie ( sessionCookieName ) ; err == nil {
candidate := strings . TrimSpace ( cookie )
if candidate != "" {
token = candidate
}
}
}
if token == "" {
respondError ( c , http . StatusUnauthorized , "session_token_required" , "session token is required" )
return nil , false
}
sess , ok := h . lookupSession ( token )
if ! ok {
respondError ( c , http . StatusUnauthorized , "invalid_session" , "session token is invalid or expired" )
return nil , false
}
user , err := h . store . GetUserByID ( c . Request . Context ( ) , sess . userID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "session_user_lookup_failed" , "failed to load session user" )
return nil , false
}
return user , true
}
2025-09-30 18:42:10 +08:00
func ( h * handler ) createSession ( userID string ) ( string , time . Time , error ) {
2025-10-02 14:06:07 +08:00
token , err := h . newRandomToken ( )
if err != nil {
2025-09-30 18:42:10 +08:00
return "" , time . Time { } , err
}
2025-10-01 10:05:31 +08:00
ttl := h . sessionTTL
if ttl <= 0 {
ttl = defaultSessionTTL
}
expiresAt := time . Now ( ) . Add ( ttl )
2025-09-30 18:42:10 +08:00
h . mu . Lock ( )
defer h . mu . Unlock ( )
h . sessions [ token ] = session { userID : userID , expiresAt : expiresAt }
return token , expiresAt , nil
}
2025-10-06 15:43:25 +08:00
func ( h * handler ) setSessionCookie ( c * gin . Context , token string , expiresAt time . Time ) {
maxAge := int ( time . Until ( expiresAt ) . Seconds ( ) )
if maxAge < 0 {
maxAge = 0
}
secure := c . Request . TLS != nil
c . SetSameSite ( http . SameSiteLaxMode )
c . SetCookie ( sessionCookieName , token , maxAge , "/" , "" , secure , true )
}
2025-09-30 18:42:10 +08:00
func ( h * handler ) lookupSession ( token string ) ( session , bool ) {
h . mu . RLock ( )
sess , ok := h . sessions [ token ]
h . mu . RUnlock ( )
if ! ok {
return session { } , false
}
if time . Now ( ) . After ( sess . expiresAt ) {
h . removeSession ( token )
return session { } , false
}
return sess , true
}
func ( h * handler ) removeSession ( token string ) {
h . mu . Lock ( )
delete ( h . sessions , token )
h . mu . Unlock ( )
}
2025-10-02 14:06:07 +08:00
func ( h * handler ) newRandomToken ( ) ( string , error ) {
buffer := make ( [ ] byte , 32 )
if _ , err := rand . Read ( buffer ) ; err != nil {
return "" , err
}
return hex . EncodeToString ( buffer ) , nil
}
2025-10-31 19:17:03 +08:00
func ( h * handler ) newVerificationCode ( ) ( string , error ) {
max := big . NewInt ( 1000000 )
n , err := rand . Int ( rand . Reader , max )
if err != nil {
return "" , err
}
return fmt . Sprintf ( "%06d" , n . Int64 ( ) ) , nil
}
2025-10-05 08:45:11 +08:00
func ( h * handler ) effectiveMFAChallengeTTL ( ) time . Duration {
2025-10-02 14:06:07 +08:00
ttl := h . mfaChallengeTTL
if ttl <= 0 {
ttl = defaultMFAChallengeTTL
}
2025-10-05 08:45:11 +08:00
return ttl
2025-10-02 14:06:07 +08:00
}
2025-10-05 08:45:11 +08:00
func clearMFAChallenge ( ch * mfaChallenge ) {
if ch == nil {
return
}
ch . totpSecret = ""
ch . totpIssuer = ""
ch . totpAccount = ""
ch . totpIssuedAt = time . Time { }
ch . failedAttempts = 0
ch . lockedUntil = time . Time { }
}
func ( h * handler ) updateMFAChallenge ( token string , update func ( * mfaChallenge ) bool ) ( mfaChallenge , bool ) {
h . mfaMu . Lock ( )
defer h . mfaMu . Unlock ( )
2025-10-02 14:06:07 +08:00
challenge , ok := h . mfaChallenges [ token ]
if ! ok {
return mfaChallenge { } , false
}
2025-10-05 08:45:11 +08:00
2025-10-02 14:06:07 +08:00
if time . Now ( ) . After ( challenge . expiresAt ) {
2025-10-05 08:45:11 +08:00
clearMFAChallenge ( & challenge )
delete ( h . mfaChallenges , token )
2025-10-02 14:06:07 +08:00
return mfaChallenge { } , false
}
2025-10-05 08:45:11 +08:00
if update != nil {
if ! update ( & challenge ) {
clearMFAChallenge ( & challenge )
delete ( h . mfaChallenges , token )
return mfaChallenge { } , false
}
h . mfaChallenges [ token ] = challenge
}
2025-10-02 14:06:07 +08:00
return challenge , true
}
2025-10-05 08:45:11 +08:00
func ( h * handler ) createMFAChallenge ( userID string ) ( string , error ) {
token , err := h . newRandomToken ( )
if err != nil {
return "" , err
2025-10-02 14:06:07 +08:00
}
2025-10-05 08:45:11 +08:00
ttl := h . effectiveMFAChallengeTTL ( )
challenge := mfaChallenge { userID : userID , expiresAt : time . Now ( ) . Add ( ttl ) }
2025-10-02 14:06:07 +08:00
h . mfaMu . Lock ( )
2025-10-05 08:45:11 +08:00
h . mfaChallenges [ token ] = challenge
2025-10-02 14:06:07 +08:00
h . mfaMu . Unlock ( )
2025-10-05 08:45:11 +08:00
return token , nil
}
func ( h * handler ) lookupMFAChallenge ( token string ) ( mfaChallenge , bool ) {
return h . updateMFAChallenge ( token , nil )
}
func ( h * handler ) refreshMFAChallenge ( token string ) ( mfaChallenge , bool ) {
ttl := h . effectiveMFAChallengeTTL ( )
return h . updateMFAChallenge ( token , func ( ch * mfaChallenge ) bool {
ch . expiresAt = time . Now ( ) . Add ( ttl )
return true
} )
2025-10-02 14:06:07 +08:00
}
2025-10-02 17:56:30 +08:00
func ( h * handler ) enqueueEmailVerification ( ctx context . Context , user * store . User ) error {
email := strings . TrimSpace ( user . Email )
if email == "" {
return errors . New ( "user email is empty" )
}
ttl := h . verificationTTL
if ttl <= 0 {
ttl = defaultEmailVerificationTTL
}
expiresAt := time . Now ( ) . Add ( ttl )
2025-10-31 19:17:03 +08:00
code , err := h . newVerificationCode ( )
if err != nil {
return err
}
normalizedEmail := strings . ToLower ( email )
2025-10-02 17:56:30 +08:00
verification := emailVerification {
userID : user . ID ,
2025-10-31 19:17:03 +08:00
email : normalizedEmail ,
code : code ,
2025-10-02 17:56:30 +08:00
expiresAt : expiresAt ,
}
h . verificationMu . Lock ( )
2025-10-31 19:17:03 +08:00
h . verifications [ normalizedEmail ] = verification
2025-10-02 17:56:30 +08:00
h . verificationMu . Unlock ( )
name := strings . TrimSpace ( user . Name )
if name == "" {
name = "there"
}
subject := "Verify your XControl account"
2025-10-31 19:17:03 +08:00
plainBody := fmt . Sprintf ( "Hello %s,\n\nUse the following verification code to verify your XControl account: %s\n\nThis code expires at %s UTC (in %d minutes).\nIf you did not request this email you can ignore it.\n" , name , code , expiresAt . UTC ( ) . Format ( time . RFC3339 ) , int ( ttl . Minutes ( ) ) )
htmlBody := fmt . Sprintf ( "<p>Hello %s,</p><p>Use the following verification code to verify your XControl account:</p><p><strong>%s</strong></p><p>This code expires at %s UTC (in %d minutes).</p><p>If you did not request this email you can ignore it.</p>" , html . EscapeString ( name ) , code , expiresAt . UTC ( ) . Format ( time . RFC3339 ) , int ( ttl . Minutes ( ) ) )
2025-10-02 17:56:30 +08:00
msg := EmailMessage {
To : [ ] string { email } ,
Subject : subject ,
PlainBody : plainBody ,
HTMLBody : htmlBody ,
}
if err := h . emailSender . Send ( ctx , msg ) ; err != nil {
2025-10-31 19:17:03 +08:00
h . removeEmailVerification ( normalizedEmail )
2025-10-02 17:56:30 +08:00
return err
}
return nil
}
2025-10-31 19:17:03 +08:00
func ( h * handler ) lookupEmailVerification ( email string ) ( emailVerification , bool ) {
email = strings . ToLower ( strings . TrimSpace ( email ) )
if email == "" {
2025-10-02 17:56:30 +08:00
return emailVerification { } , false
}
h . verificationMu . RLock ( )
2025-10-31 19:17:03 +08:00
verification , ok := h . verifications [ email ]
2025-10-02 17:56:30 +08:00
h . verificationMu . RUnlock ( )
if ! ok {
return emailVerification { } , false
}
if time . Now ( ) . After ( verification . expiresAt ) {
2025-10-31 19:17:03 +08:00
h . removeEmailVerification ( email )
2025-10-02 17:56:30 +08:00
return emailVerification { } , false
}
return verification , true
}
2025-10-31 19:17:03 +08:00
func ( h * handler ) removeEmailVerification ( email string ) {
2025-10-02 17:56:30 +08:00
h . verificationMu . Lock ( )
2025-10-31 19:17:03 +08:00
delete ( h . verifications , strings . ToLower ( strings . TrimSpace ( email ) ) )
2025-10-02 17:56:30 +08:00
h . verificationMu . Unlock ( )
}
2025-11-01 21:34:35 +08:00
func ( h * handler ) issueRegistrationVerification ( ctx context . Context , email string ) ( registrationVerification , error ) {
normalized := strings . ToLower ( strings . TrimSpace ( email ) )
if normalized == "" {
return registrationVerification { } , errors . New ( "email is empty" )
}
ttl := h . verificationTTL
if ttl <= 0 {
ttl = defaultEmailVerificationTTL
}
code , err := h . newVerificationCode ( )
if err != nil {
return registrationVerification { } , err
}
verification := registrationVerification {
email : normalized ,
code : code ,
expiresAt : time . Now ( ) . Add ( ttl ) ,
}
h . registrationMu . Lock ( )
h . registrationVerifications [ normalized ] = verification
h . registrationMu . Unlock ( )
trimmedEmail := strings . TrimSpace ( email )
if trimmedEmail == "" {
trimmedEmail = normalized
}
subject := "Verify your email for XControl"
plainBody := fmt . Sprintf (
"Hello,\n\nUse the following verification code to continue creating your XControl account: %s\n\nThis code expires at %s UTC (in %d minutes).\nIf you did not request this email you can ignore it.\n" ,
code ,
verification . expiresAt . UTC ( ) . Format ( time . RFC3339 ) ,
int ( ttl . Minutes ( ) ) ,
)
htmlBody := fmt . Sprintf (
"<p>Hello,</p><p>Use the following verification code to continue creating your XControl account:</p><p><strong>%s</strong></p><p>This code expires at %s UTC (in %d minutes).</p><p>If you did not request this email you can ignore it.</p>" ,
html . EscapeString ( code ) ,
verification . expiresAt . UTC ( ) . Format ( time . RFC3339 ) ,
int ( ttl . Minutes ( ) ) ,
)
msg := EmailMessage {
To : [ ] string { trimmedEmail } ,
Subject : subject ,
PlainBody : plainBody ,
HTMLBody : htmlBody ,
}
if err := h . emailSender . Send ( ctx , msg ) ; err != nil {
h . removeRegistrationVerification ( normalized )
return registrationVerification { } , err
}
return verification , nil
}
func ( h * handler ) lookupRegistrationVerification ( email string ) ( registrationVerification , bool ) {
email = strings . ToLower ( strings . TrimSpace ( email ) )
if email == "" {
return registrationVerification { } , false
}
h . registrationMu . RLock ( )
verification , ok := h . registrationVerifications [ email ]
h . registrationMu . RUnlock ( )
if ! ok {
return registrationVerification { } , false
}
if time . Now ( ) . After ( verification . expiresAt ) {
h . removeRegistrationVerification ( email )
return registrationVerification { } , false
}
return verification , true
}
func ( h * handler ) markRegistrationVerified ( email string ) bool {
email = strings . ToLower ( strings . TrimSpace ( email ) )
if email == "" {
return false
}
h . registrationMu . Lock ( )
defer h . registrationMu . Unlock ( )
verification , ok := h . registrationVerifications [ email ]
if ! ok {
return false
}
if time . Now ( ) . After ( verification . expiresAt ) {
delete ( h . registrationVerifications , email )
return false
}
verification . verified = true
h . registrationVerifications [ email ] = verification
return true
}
func ( h * handler ) removeRegistrationVerification ( email string ) {
h . registrationMu . Lock ( )
delete ( h . registrationVerifications , strings . ToLower ( strings . TrimSpace ( email ) ) )
h . registrationMu . Unlock ( )
}
2025-10-02 17:56:30 +08:00
func ( h * handler ) enqueuePasswordReset ( ctx context . Context , user * store . User ) error {
email := strings . TrimSpace ( user . Email )
if email == "" {
return errors . New ( "user email is empty" )
}
token , err := h . newRandomToken ( )
if err != nil {
return err
}
ttl := h . resetTTL
if ttl <= 0 {
ttl = defaultPasswordResetTTL
}
expiresAt := time . Now ( ) . Add ( ttl )
reset := passwordReset {
userID : user . ID ,
email : strings . ToLower ( email ) ,
expiresAt : expiresAt ,
}
h . resetMu . Lock ( )
h . passwordResets [ token ] = reset
h . resetMu . Unlock ( )
name := strings . TrimSpace ( user . Name )
if name == "" {
name = "there"
}
subject := "Reset your XControl password"
plainBody := fmt . Sprintf ( "Hello %s,\n\nUse the following token to reset your XControl account password: %s\n\nThis token expires at %s UTC.\nIf you did not request a reset you can ignore this email.\n" , name , token , expiresAt . UTC ( ) . Format ( time . RFC3339 ) )
htmlBody := fmt . Sprintf ( "<p>Hello %s,</p><p>Use the following token to reset your XControl account password:</p><p><strong>%s</strong></p><p>This token expires at %s UTC.</p><p>If you did not request a reset you can ignore this email.</p>" , html . EscapeString ( name ) , token , expiresAt . UTC ( ) . Format ( time . RFC3339 ) )
msg := EmailMessage {
To : [ ] string { email } ,
Subject : subject ,
PlainBody : plainBody ,
HTMLBody : htmlBody ,
}
if err := h . emailSender . Send ( ctx , msg ) ; err != nil {
h . removePasswordReset ( token )
return err
}
return nil
}
func ( h * handler ) lookupPasswordReset ( token string ) ( passwordReset , bool ) {
token = strings . TrimSpace ( token )
if token == "" {
return passwordReset { } , false
}
h . resetMu . RLock ( )
reset , ok := h . passwordResets [ token ]
h . resetMu . RUnlock ( )
if ! ok {
return passwordReset { } , false
}
if time . Now ( ) . After ( reset . expiresAt ) {
h . removePasswordReset ( token )
return passwordReset { } , false
}
return reset , true
}
func ( h * handler ) removePasswordReset ( token string ) {
h . resetMu . Lock ( )
delete ( h . passwordResets , strings . TrimSpace ( token ) )
h . resetMu . Unlock ( )
}
2025-10-02 14:06:07 +08:00
func ( h * handler ) removeMFAChallenge ( token string ) {
h . mfaMu . Lock ( )
2025-10-05 08:45:11 +08:00
if challenge , ok := h . mfaChallenges [ token ] ; ok {
clearMFAChallenge ( & challenge )
delete ( h . mfaChallenges , token )
}
2025-10-02 14:06:07 +08:00
h . mfaMu . Unlock ( )
}
2025-10-04 20:38:16 +08:00
func ( h * handler ) removeMFAChallengesForUser ( userID string ) {
if userID == "" {
return
}
h . mfaMu . Lock ( )
for token , challenge := range h . mfaChallenges {
if challenge . userID == userID {
2025-10-05 08:45:11 +08:00
clearMFAChallenge ( & challenge )
2025-10-04 20:38:16 +08:00
delete ( h . mfaChallenges , token )
}
}
h . mfaMu . Unlock ( )
}
2025-10-02 14:06:07 +08:00
func ( h * handler ) provisionTOTP ( c * gin . Context ) {
var req struct {
Token string ` json:"token" `
Issuer string ` json:"issuer" `
Account string ` json:"account" `
}
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
token := strings . TrimSpace ( req . Token )
2025-10-05 09:33:47 +08:00
ctx := c . Request . Context ( )
var (
user * store . User
err error
challenge mfaChallenge
ok bool
)
if token != "" {
challenge , ok = h . refreshMFAChallenge ( token )
if ! ok {
respondError ( c , http . StatusUnauthorized , "invalid_mfa_token" , "mfa token is invalid or expired" )
return
}
2025-10-02 14:06:07 +08:00
2025-10-05 09:33:47 +08:00
user , err = h . store . GetUserByID ( ctx , challenge . userID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_user_lookup_failed" , "failed to load user for mfa provisioning" )
return
}
} else {
sessionToken := extractToken ( c . GetHeader ( "Authorization" ) )
if sessionToken == "" {
respondError ( c , http . StatusBadRequest , "mfa_token_required" , "mfa token or valid session is required" )
return
}
2025-10-02 14:06:07 +08:00
2025-10-05 09:33:47 +08:00
sess , ok := h . lookupSession ( sessionToken )
if ! ok {
respondError ( c , http . StatusUnauthorized , "invalid_session" , "session token is invalid or expired" )
return
}
user , err = h . store . GetUserByID ( ctx , sess . userID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_user_lookup_failed" , "failed to load user for mfa provisioning" )
return
}
challengeToken , err := h . createMFAChallenge ( user . ID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_challenge_creation_failed" , "failed to create mfa challenge" )
return
}
token = challengeToken
challenge , ok = h . refreshMFAChallenge ( token )
if ! ok {
respondError ( c , http . StatusInternalServerError , "mfa_challenge_creation_failed" , "failed to initialize mfa challenge" )
return
}
2025-10-02 14:06:07 +08:00
}
if user . MFAEnabled {
respondError ( c , http . StatusBadRequest , "mfa_already_enabled" , "mfa already enabled for this account" )
return
}
issuer := strings . TrimSpace ( req . Issuer )
if issuer == "" {
2025-10-05 08:45:11 +08:00
issuer = strings . TrimSpace ( h . totpIssuer )
if issuer == "" {
issuer = defaultTOTPIssuer
}
2025-10-02 14:06:07 +08:00
}
accountName := strings . TrimSpace ( req . Account )
if accountName == "" {
2025-10-05 08:45:11 +08:00
accountName = deriveDefaultAccountLabel ( user , issuer )
2025-10-02 14:06:07 +08:00
}
key , err := totp . Generate ( totp . GenerateOpts {
Issuer : issuer ,
AccountName : accountName ,
Period : 30 ,
Digits : otp . DigitsSix ,
Algorithm : otp . AlgorithmSHA1 ,
} )
if err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_secret_generation_failed" , "failed to generate totp secret" )
return
}
2025-10-05 08:45:11 +08:00
issuedAt := time . Now ( ) . UTC ( )
ttl := h . effectiveMFAChallengeTTL ( )
2025-10-27 11:41:05 +08:00
pendingChallenge , ok := h . refreshMFAChallenge ( token )
if ! ok || pendingChallenge . userID != user . ID {
respondError ( c , http . StatusUnauthorized , "invalid_mfa_token" , "mfa token is invalid or expired" )
return
}
secret := strings . TrimSpace ( key . Secret ( ) )
previousSecret := user . MFATOTPSecret
previousIssuedAt := user . MFASecretIssuedAt
previousConfirmedAt := user . MFAConfirmedAt
previousEnabled := user . MFAEnabled
user . MFATOTPSecret = secret
user . MFASecretIssuedAt = issuedAt
user . MFAConfirmedAt = time . Time { }
user . MFAEnabled = false
if err := h . store . UpdateUser ( ctx , user ) ; err != nil {
user . MFATOTPSecret = previousSecret
user . MFASecretIssuedAt = previousIssuedAt
user . MFAConfirmedAt = previousConfirmedAt
user . MFAEnabled = previousEnabled
respondError ( c , http . StatusInternalServerError , "mfa_setup_failed" , "failed to persist mfa provisioning state" )
return
}
pendingChallenge , ok = h . updateMFAChallenge ( token , func ( ch * mfaChallenge ) bool {
2025-10-05 08:45:11 +08:00
if ch . userID != user . ID {
return false
}
2025-10-27 11:41:05 +08:00
ch . totpSecret = secret
2025-10-05 08:45:11 +08:00
ch . totpIssuer = issuer
ch . totpAccount = accountName
ch . totpIssuedAt = issuedAt
ch . failedAttempts = 0
ch . lockedUntil = time . Time { }
ch . expiresAt = time . Now ( ) . Add ( ttl )
return true
} )
if ! ok {
2025-10-27 11:41:05 +08:00
user . MFATOTPSecret = previousSecret
user . MFASecretIssuedAt = previousIssuedAt
user . MFAConfirmedAt = previousConfirmedAt
user . MFAEnabled = previousEnabled
if err := h . store . UpdateUser ( ctx , user ) ; err != nil {
slog . Error ( "failed to revert mfa provisioning state" , "err" , err , "userID" , user . ID )
}
respondError ( c , http . StatusInternalServerError , "mfa_challenge_creation_failed" , "failed to initialize mfa challenge" )
2025-10-02 14:06:07 +08:00
return
}
2025-10-05 08:45:11 +08:00
state := buildMFAState ( user , & pendingChallenge )
sanitized := sanitizeUser ( user , & pendingChallenge )
2025-10-02 14:06:07 +08:00
c . JSON ( http . StatusOK , gin . H {
2025-10-27 11:41:05 +08:00
"secret" : secret ,
2025-10-05 08:45:11 +08:00
"otpauth_url" : key . URL ( ) ,
"issuer" : issuer ,
"account" : accountName ,
"mfaToken" : token ,
"mfa" : state ,
"user" : sanitized ,
2025-10-02 14:06:07 +08:00
} )
}
2025-10-05 08:45:11 +08:00
func deriveDefaultAccountLabel ( user * store . User , issuer string ) string {
if user == nil {
if issuer == "" {
return "account"
}
return fmt . Sprintf ( "%s account" , issuer )
}
identifier := strings . TrimSpace ( user . ID )
if identifier == "" {
if issuer == "" {
return "account"
}
return fmt . Sprintf ( "%s account" , issuer )
}
sum := sha1 . Sum ( [ ] byte ( identifier ) )
encoder := base32 . StdEncoding . WithPadding ( base32 . NoPadding )
encoded := strings . ToLower ( encoder . EncodeToString ( sum [ : ] ) )
if len ( encoded ) > 10 {
encoded = encoded [ : 10 ]
}
return fmt . Sprintf ( "user-%s" , encoded )
}
2025-10-02 14:06:07 +08:00
func ( h * handler ) verifyTOTP ( c * gin . Context ) {
var req struct {
Token string ` json:"token" `
Code string ` json:"code" `
}
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
token := strings . TrimSpace ( req . Token )
if token == "" {
respondError ( c , http . StatusBadRequest , "mfa_token_required" , "mfa token is required" )
return
}
challenge , ok := h . lookupMFAChallenge ( token )
if ! ok {
respondError ( c , http . StatusUnauthorized , "invalid_mfa_token" , "mfa token is invalid or expired" )
return
}
ctx := c . Request . Context ( )
user , err := h . store . GetUserByID ( ctx , challenge . userID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_user_lookup_failed" , "failed to load user for verification" )
return
}
2025-10-05 08:45:11 +08:00
challenge , ok = h . updateMFAChallenge ( token , func ( ch * mfaChallenge ) bool {
if ch . userID != user . ID {
return false
}
ch . expiresAt = time . Now ( ) . Add ( h . effectiveMFAChallengeTTL ( ) )
return true
} )
if ! ok {
respondError ( c , http . StatusUnauthorized , "invalid_mfa_token" , "mfa token is invalid or expired" )
return
}
now := time . Now ( )
if ! challenge . lockedUntil . IsZero ( ) && now . Before ( challenge . lockedUntil ) {
retryAt := challenge . lockedUntil . UTC ( )
c . JSON ( http . StatusTooManyRequests , gin . H {
"error" : "mfa_challenge_locked" ,
"message" : "too many invalid mfa attempts, try again later" ,
"retryAt" : retryAt ,
"mfaToken" : token ,
} )
return
}
secret := strings . TrimSpace ( user . MFATOTPSecret )
if secret == "" {
secret = strings . TrimSpace ( challenge . totpSecret )
}
if secret == "" {
2025-10-02 14:06:07 +08:00
respondError ( c , http . StatusBadRequest , "mfa_secret_missing" , "mfa secret has not been provisioned" )
return
}
code := strings . TrimSpace ( req . Code )
if code == "" {
respondError ( c , http . StatusBadRequest , "mfa_code_required" , "totp code is required" )
return
}
2025-10-05 08:45:11 +08:00
valid , err := totp . ValidateCustom ( code , secret , time . Now ( ) . UTC ( ) , totp . ValidateOpts {
2025-10-02 15:16:38 +08:00
Period : 30 ,
Skew : 1 ,
Digits : otp . DigitsSix ,
Algorithm : otp . AlgorithmSHA1 ,
} )
if err != nil {
respondError ( c , http . StatusInternalServerError , "invalid_mfa_code" , "invalid totp code" )
return
}
if ! valid {
2025-10-05 08:45:11 +08:00
ttl := h . effectiveMFAChallengeTTL ( )
updatedChallenge , ok := h . updateMFAChallenge ( token , func ( ch * mfaChallenge ) bool {
if ch . userID != user . ID {
return false
}
if now . Before ( ch . lockedUntil ) {
return true
}
ch . failedAttempts ++
if ch . failedAttempts >= maxMFAVerificationAttempts {
ch . failedAttempts = 0
ch . lockedUntil = now . Add ( defaultMFALockoutDuration )
}
ch . expiresAt = time . Now ( ) . Add ( ttl )
return true
} )
if ok {
challenge = updatedChallenge
}
if ! challenge . lockedUntil . IsZero ( ) && now . Before ( challenge . lockedUntil ) {
retryAt := challenge . lockedUntil . UTC ( )
c . JSON ( http . StatusTooManyRequests , gin . H {
"error" : "mfa_challenge_locked" ,
"message" : "too many invalid mfa attempts, try again later" ,
"retryAt" : retryAt ,
"mfaToken" : token ,
} )
return
}
2025-10-02 14:06:07 +08:00
respondError ( c , http . StatusUnauthorized , "invalid_mfa_code" , "invalid totp code" )
return
}
2025-10-05 08:45:11 +08:00
confirmationTime := time . Now ( ) . UTC ( )
issuedAt := challenge . totpIssuedAt
if issuedAt . IsZero ( ) {
issuedAt = confirmationTime
}
if strings . TrimSpace ( user . MFATOTPSecret ) == "" {
user . MFATOTPSecret = secret
}
if user . MFASecretIssuedAt . IsZero ( ) {
user . MFASecretIssuedAt = issuedAt
}
2025-10-02 14:06:07 +08:00
user . MFAEnabled = true
2025-10-05 08:45:11 +08:00
user . MFAConfirmedAt = confirmationTime
2025-10-02 14:06:07 +08:00
if err := h . store . UpdateUser ( ctx , user ) ; err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_update_failed" , "failed to enable mfa" )
return
}
h . removeMFAChallenge ( token )
sessionToken , expiresAt , err := h . createSession ( user . ID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "session_creation_failed" , "failed to create session" )
return
}
2025-10-06 15:43:25 +08:00
h . setSessionCookie ( c , sessionToken , expiresAt )
2025-10-02 14:06:07 +08:00
c . JSON ( http . StatusOK , gin . H {
"message" : "mfa_verified" ,
"token" : sessionToken ,
"expiresAt" : expiresAt . UTC ( ) ,
2025-10-05 08:45:11 +08:00
"user" : sanitizeUser ( user , nil ) ,
2025-10-02 14:06:07 +08:00
} )
}
func ( h * handler ) mfaStatus ( c * gin . Context ) {
token := strings . TrimSpace ( c . Query ( "token" ) )
if token == "" {
token = strings . TrimSpace ( c . GetHeader ( "X-MFA-Token" ) )
}
2025-10-04 20:37:07 +08:00
identifier := strings . TrimSpace ( c . Query ( "identifier" ) )
if identifier == "" {
identifier = strings . TrimSpace ( c . Query ( "email" ) )
}
2025-10-02 14:06:07 +08:00
authToken := extractToken ( c . GetHeader ( "Authorization" ) )
var (
2025-10-05 08:45:11 +08:00
user * store . User
err error
challenge * mfaChallenge
2025-10-02 14:06:07 +08:00
)
ctx := c . Request . Context ( )
if authToken != "" {
if sess , ok := h . lookupSession ( authToken ) ; ok {
user , err = h . store . GetUserByID ( ctx , sess . userID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_status_failed" , "failed to load user for status" )
return
}
} else if token == "" {
token = authToken
}
}
2025-10-05 08:45:11 +08:00
if token != "" {
if refreshed , ok := h . refreshMFAChallenge ( token ) ; ok {
if user != nil && user . ID != refreshed . userID {
challenge = nil
} else {
challenge = & refreshed
if user == nil {
user , err = h . store . GetUserByID ( ctx , refreshed . userID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_status_failed" , "failed to load user for status" )
return
}
}
2025-10-02 14:06:07 +08:00
}
}
}
2025-10-04 20:37:07 +08:00
if user == nil && identifier != "" {
user , err = h . findUserByIdentifier ( ctx , identifier )
if err != nil {
if errors . Is ( err , store . ErrUserNotFound ) {
respondError ( c , http . StatusNotFound , "user_not_found" , "user not found" )
return
}
respondError ( c , http . StatusInternalServerError , "mfa_status_failed" , "failed to load user for status" )
return
}
}
2025-10-02 14:06:07 +08:00
if user == nil {
respondError ( c , http . StatusUnauthorized , "mfa_token_required" , "valid session or mfa token is required" )
return
}
2025-10-05 08:45:11 +08:00
state := buildMFAState ( user , challenge )
2025-10-02 14:06:07 +08:00
c . JSON ( http . StatusOK , gin . H {
2025-10-05 08:45:11 +08:00
"enabled" : user . MFAEnabled ,
"mfa" : state ,
"user" : sanitizeUser ( user , challenge ) ,
2025-10-02 14:06:07 +08:00
} )
}
2025-11-21 18:55:12 +08:00
func ( h * handler ) listSubscriptions ( c * gin . Context ) {
user , ok := h . requireAuthenticatedUser ( c )
if ! ok {
return
}
subscriptions , err := h . store . ListSubscriptionsByUser ( c . Request . Context ( ) , user . ID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "subscriptions_unavailable" , "failed to load subscriptions" )
return
}
sanitized := make ( [ ] gin . H , 0 , len ( subscriptions ) )
for i := range subscriptions {
sanitized = append ( sanitized , sanitizeSubscription ( & subscriptions [ i ] ) )
}
c . JSON ( http . StatusOK , gin . H { "subscriptions" : sanitized } )
}
func ( h * handler ) upsertSubscription ( c * gin . Context ) {
user , ok := h . requireAuthenticatedUser ( c )
if ! ok {
return
}
var req subscriptionUpsertRequest
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
externalID := strings . TrimSpace ( req . ExternalID )
if externalID == "" {
respondError ( c , http . StatusBadRequest , "external_id_required" , "externalId is required" )
return
}
provider := strings . TrimSpace ( req . Provider )
if provider == "" {
provider = "paypal"
}
2025-11-21 19:20:51 +08:00
paymentMethod := strings . TrimSpace ( req . PaymentMethod )
if paymentMethod == "" {
paymentMethod = provider
}
paymentQRCode := strings . TrimSpace ( req . PaymentQRCode )
2025-11-21 18:55:12 +08:00
kind := strings . TrimSpace ( req . Kind )
if kind == "" {
kind = "subscription"
}
status := strings . TrimSpace ( req . Status )
if status == "" {
status = "active"
}
sub := & store . Subscription {
2025-11-21 19:20:51 +08:00
UserID : user . ID ,
Provider : provider ,
PaymentMethod : paymentMethod ,
PaymentQRCode : paymentQRCode ,
Kind : kind ,
PlanID : strings . TrimSpace ( req . PlanID ) ,
ExternalID : externalID ,
Status : status ,
Meta : req . Meta ,
2025-11-21 18:55:12 +08:00
}
if err := h . store . UpsertSubscription ( c . Request . Context ( ) , sub ) ; err != nil {
respondError ( c , http . StatusInternalServerError , "subscription_upsert_failed" , "failed to persist subscription state" )
return
}
c . JSON ( http . StatusOK , gin . H { "subscription" : sanitizeSubscription ( sub ) } )
}
func ( h * handler ) cancelSubscription ( c * gin . Context ) {
user , ok := h . requireAuthenticatedUser ( c )
if ! ok {
return
}
var req subscriptionCancelRequest
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
externalID := strings . TrimSpace ( req . ExternalID )
if externalID == "" {
respondError ( c , http . StatusBadRequest , "external_id_required" , "externalId is required" )
return
}
sub , err := h . store . CancelSubscription ( c . Request . Context ( ) , user . ID , externalID , time . Now ( ) . UTC ( ) )
if err != nil {
if errors . Is ( err , store . ErrSubscriptionNotFound ) {
respondError ( c , http . StatusNotFound , "subscription_not_found" , "subscription not found" )
return
}
respondError ( c , http . StatusInternalServerError , "subscription_cancel_failed" , "failed to update subscription" )
return
}
c . JSON ( http . StatusOK , gin . H { "subscription" : sanitizeSubscription ( sub ) } )
}
2025-10-05 08:45:11 +08:00
func sanitizeUser ( user * store . User , challenge * mfaChallenge ) gin . H {
2025-10-01 17:42:50 +08:00
identifier := strings . TrimSpace ( user . ID )
2025-10-07 08:23:53 +08:00
groups := user . Groups
if len ( groups ) == 0 {
groups = [ ] string { }
} else {
cloned := make ( [ ] string , len ( groups ) )
copy ( cloned , groups )
groups = cloned
}
permissions := user . Permissions
if len ( permissions ) == 0 {
permissions = [ ] string { }
} else {
cloned := make ( [ ] string , len ( permissions ) )
copy ( cloned , permissions )
permissions = cloned
}
2025-09-30 18:42:10 +08:00
return gin . H {
2025-10-02 17:56:30 +08:00
"id" : identifier ,
"uuid" : identifier ,
"name" : user . Name ,
"username" : user . Name ,
"email" : user . Email ,
"emailVerified" : user . EmailVerified ,
"mfaEnabled" : user . MFAEnabled ,
2025-10-05 08:45:11 +08:00
"mfa" : buildMFAState ( user , challenge ) ,
2025-10-07 08:23:53 +08:00
"role" : user . Role ,
"groups" : groups ,
"permissions" : permissions ,
2025-10-02 14:06:07 +08:00
}
}
2025-11-21 18:55:12 +08:00
func sanitizeSubscription ( sub * store . Subscription ) gin . H {
if sub == nil {
return gin . H { }
}
meta := map [ string ] any { }
for key , value := range sub . Meta {
meta [ key ] = value
}
payload := gin . H {
2025-11-21 19:20:51 +08:00
"id" : sub . ID ,
"userId" : sub . UserID ,
"provider" : sub . Provider ,
"paymentMethod" : sub . PaymentMethod ,
"paymentQr" : strings . TrimSpace ( sub . PaymentQRCode ) ,
"kind" : sub . Kind ,
"planId" : sub . PlanID ,
"externalId" : sub . ExternalID ,
"status" : sub . Status ,
"meta" : meta ,
"createdAt" : sub . CreatedAt . UTC ( ) ,
"updatedAt" : sub . UpdatedAt . UTC ( ) ,
2025-11-21 18:55:12 +08:00
}
if sub . CancelledAt != nil {
payload [ "cancelledAt" ] = sub . CancelledAt . UTC ( )
}
return payload
}
2025-10-05 08:45:11 +08:00
func buildMFAState ( user * store . User , challenge * mfaChallenge ) gin . H {
pending := strings . TrimSpace ( user . MFATOTPSecret ) != "" && ! user . MFAEnabled
issuedAt := user . MFASecretIssuedAt
if challenge != nil && ! user . MFAEnabled {
if strings . TrimSpace ( challenge . totpSecret ) != "" {
pending = true
}
if issuedAt . IsZero ( ) && ! challenge . totpIssuedAt . IsZero ( ) {
issuedAt = challenge . totpIssuedAt
}
}
2025-10-02 14:06:07 +08:00
state := gin . H {
"totpEnabled" : user . MFAEnabled ,
2025-10-05 08:45:11 +08:00
"totpPending" : pending ,
2025-10-02 14:06:07 +08:00
}
2025-10-05 08:45:11 +08:00
if ! issuedAt . IsZero ( ) {
state [ "totpSecretIssuedAt" ] = issuedAt . UTC ( )
2025-10-02 14:06:07 +08:00
}
if ! user . MFAConfirmedAt . IsZero ( ) {
state [ "totpConfirmedAt" ] = user . MFAConfirmedAt . UTC ( )
2025-09-30 18:42:10 +08:00
}
2025-10-05 08:45:11 +08:00
if challenge != nil && ! challenge . lockedUntil . IsZero ( ) && time . Now ( ) . Before ( challenge . lockedUntil ) {
state [ "totpLockedUntil" ] = challenge . lockedUntil . UTC ( )
}
2025-10-02 14:06:07 +08:00
return state
2025-09-30 18:42:10 +08:00
}
2025-10-04 20:38:16 +08:00
func ( h * handler ) disableMFA ( c * gin . Context ) {
token := extractToken ( c . GetHeader ( "Authorization" ) )
if token == "" {
token = strings . TrimSpace ( c . Query ( "token" ) )
}
if token == "" {
respondError ( c , http . StatusUnauthorized , "session_token_required" , "session token is required" )
return
}
sess , ok := h . lookupSession ( token )
if ! ok {
respondError ( c , http . StatusUnauthorized , "invalid_session" , "session token is invalid or expired" )
return
}
ctx := c . Request . Context ( )
user , err := h . store . GetUserByID ( ctx , sess . userID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_disable_failed" , "failed to load user for mfa disable" )
return
}
hasSecret := strings . TrimSpace ( user . MFATOTPSecret ) != ""
if ! user . MFAEnabled && ! hasSecret {
respondError ( c , http . StatusBadRequest , "mfa_not_enabled" , "multi-factor authentication is not enabled" )
return
}
user . MFATOTPSecret = ""
user . MFAEnabled = false
user . MFASecretIssuedAt = time . Time { }
user . MFAConfirmedAt = time . Time { }
if err := h . store . UpdateUser ( ctx , user ) ; err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_disable_failed" , "failed to disable mfa" )
return
}
h . removeMFAChallengesForUser ( user . ID )
c . JSON ( http . StatusOK , gin . H {
"message" : "mfa_disabled" ,
2025-10-05 08:45:11 +08:00
"user" : sanitizeUser ( user , nil ) ,
2025-10-04 20:38:16 +08:00
} )
}
2025-10-01 07:52:39 +08:00
func respondError ( c * gin . Context , status int , code , message string ) {
c . JSON ( status , gin . H {
"error" : code ,
"message" : message ,
} )
}
2025-09-30 18:42:10 +08:00
func extractToken ( header string ) string {
if header == "" {
return ""
}
const prefix = "Bearer "
if strings . HasPrefix ( header , prefix ) {
header = header [ len ( prefix ) : ]
}
return strings . TrimSpace ( header )
}