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"
2026-01-30 23:12:01 +08:00
"net/url"
2026-04-12 13:42:48 +08:00
"os"
2026-03-13 11:18:51 +08:00
"reflect"
2025-09-30 18:42:10 +08:00
"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"
2026-02-06 13:03:47 +08:00
"gorm.io/gorm"
2025-09-30 18:42:10 +08:00
2026-02-06 16:40:31 +08:00
"account/internal/agentproto"
"account/internal/agentserver"
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
2026-03-17 08:51:01 +08:00
const defaultOAuthExchangeCodeTTL = 5 * time . Minute
2025-09-30 18:42:10 +08:00
2025-10-06 15:17:35 +08:00
const sessionCookieName = "xc_session"
2026-04-12 13:42:48 +08:00
type imageVersionInfo struct {
ImageRef string ` json:"image_ref" `
Tag string ` json:"tag,omitempty" `
Commit string ` json:"commit,omitempty" `
Version string ` json:"version,omitempty" `
}
2025-09-30 18:42:10 +08:00
type session struct {
userID string
expiresAt time . Time
}
2026-03-17 08:51:01 +08:00
type oauthExchangeCode struct {
sessionToken string
sessionExpiresAt time . Time
expiresAt time . Time
}
2025-09-30 18:42:10 +08:00
type handler struct {
2025-11-01 21:34:35 +08:00
store store . Store
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
2026-03-17 08:51:01 +08:00
oauthExchangeCodes map [ string ] oauthExchangeCode
oauthExchangeMu sync . RWMutex
oauthExchangeTTL time . Duration
2025-11-01 21:34:35 +08:00
metricsProvider service . UserMetricsProvider
agentStatusReader agentStatusReader
2025-11-05 20:11:23 +08:00
tokenService * auth . TokenService
2026-01-30 08:46:24 +08:00
oauthProviders map [ string ] auth . OAuthProvider
oauthFrontendURL string
2026-01-31 13:52:31 +08:00
publicURL string
2026-04-02 09:14:19 +08:00
xworkmateVaultService xworkmateVaultService
2026-03-17 10:06:39 +08:00
xrayConfigRenderer func ( * store . User ) ( string , string , [ ] string , error )
2026-02-06 13:03:47 +08:00
agentRegistry agentRegistry
db * gorm . DB
2026-03-16 20:09:58 +08:00
stripe * stripeClient
2026-02-06 13:03:47 +08:00
}
type agentRegistry interface {
IsSandboxAgent ( agentID string ) bool
SetSandboxAgent ( agentID string , enabled bool )
2026-02-06 16:40:31 +08:00
ClearSandboxAgents ( )
Authenticate ( token string ) ( * agentserver . Identity , bool )
RegisterAgent ( agentID string , groups [ ] string ) agentserver . Identity
ReportStatus ( agent agentserver . Identity , report agentproto . StatusReport )
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
}
}
}
2026-01-30 08:46:24 +08:00
// WithOAuthProviders configures the handler with the provided OAuth2 providers.
func WithOAuthProviders ( providers map [ string ] auth . OAuthProvider ) Option {
return func ( h * handler ) {
h . oauthProviders = providers
}
}
2026-01-31 13:52:31 +08:00
// WithServerPublicURL configures the public URL of the account service.
func WithServerPublicURL ( url string ) Option {
return func ( h * handler ) {
h . publicURL = url
}
}
2026-03-17 10:06:39 +08:00
// WithXrayConfigRenderer overrides sync config rendering.
// It exists primarily to make sync endpoint behavior testable.
func WithXrayConfigRenderer ( renderer func ( * store . User ) ( string , string , [ ] string , error ) ) Option {
return func ( h * handler ) {
if renderer != nil {
h . xrayConfigRenderer = renderer
}
}
}
2026-01-30 08:46:24 +08:00
// WithOAuthFrontendURL configures the frontend URL for OAuth2 redirects.
func WithOAuthFrontendURL ( url string ) Option {
return func ( h * handler ) {
h . oauthFrontendURL = url
}
}
2026-04-02 09:14:19 +08:00
// WithXWorkmateVaultService configures the Vault-backed secret service used by
// xworkmate integration endpoints.
func WithXWorkmateVaultService ( vaultService xworkmateVaultService ) Option {
return func ( h * handler ) {
h . xworkmateVaultService = vaultService
}
}
2026-02-06 13:03:47 +08:00
// WithAgentRegistry configures the handler with the provided agent registry.
func WithAgentRegistry ( registry agentRegistry ) Option {
return func ( h * handler ) {
2026-03-13 11:18:51 +08:00
if isNilAgentRegistry ( registry ) {
return
}
2026-02-06 13:03:47 +08:00
h . agentRegistry = registry
}
}
2026-03-13 11:18:51 +08:00
func isNilAgentRegistry ( registry agentRegistry ) bool {
if registry == nil {
return true
}
value := reflect . ValueOf ( registry )
switch value . Kind ( ) {
case reflect . Chan , reflect . Func , reflect . Interface , reflect . Map , reflect . Pointer , reflect . Slice :
return value . IsNil ( )
default :
return false
}
}
2026-02-06 13:03:47 +08:00
// WithGormDB configures the handler with the provided GORM database for admin settings.
func WithGormDB ( db * gorm . DB ) Option {
return func ( h * handler ) {
h . db = db
}
}
2026-03-16 20:09:58 +08:00
// WithStripeConfig configures Stripe billing integration.
func WithStripeConfig ( cfg StripeConfig ) Option {
return func ( h * handler ) {
h . stripe = newStripeClient ( cfg )
}
}
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 ( ) ,
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 ) ,
2026-03-17 08:51:01 +08:00
oauthExchangeCodes : make ( map [ string ] oauthExchangeCode ) ,
oauthExchangeTTL : defaultOAuthExchangeCodeTTL ,
2025-10-01 10:05:31 +08:00
}
for _ , opt := range opts {
opt ( h )
2025-09-30 18:42:10 +08:00
}
2026-02-06 19:02:48 +08:00
if h . tokenService != nil && h . store != nil {
h . tokenService . SetStore ( h . store )
}
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" } )
} )
2026-04-12 13:42:48 +08:00
r . GET ( "/api/ping" , func ( c * gin . Context ) {
info := parseImageVersionInfo ( os . Getenv ( "IMAGE" ) )
c . JSON ( http . StatusOK , gin . H {
"status" : "ok" ,
"image" : info . ImageRef ,
"tag" : info . Tag ,
"commit" : info . Commit ,
"version" : info . Version ,
} )
} )
2026-02-02 20:29:10 +08:00
authGroup := r . Group ( "/api/auth" )
2025-10-07 09:41:27 +08:00
2026-02-02 20:29:10 +08:00
authGroup . POST ( "/register" , h . register )
authGroup . POST ( "/register/verify" , h . verifyEmail )
authGroup . POST ( "/register/send" , h . sendEmailVerification )
2025-10-07 09:41:27 +08:00
2026-02-02 20:29:10 +08:00
authGroup . POST ( "/login" , h . login )
2026-02-17 11:59:18 +08:00
authGroup . POST ( "/mfa/verify" , h . verifyMFALogin )
2025-10-07 09:41:27 +08:00
2026-03-17 08:51:01 +08:00
// Token exchange endpoint - converts one-time OAuth exchange code to a real session token.
2026-02-02 20:29:10 +08:00
authGroup . POST ( "/token/exchange" , h . exchangeToken )
2025-11-05 20:11:23 +08:00
2026-01-30 08:46:24 +08:00
// OAuth2 routes
2026-02-02 20:29:10 +08:00
authGroup . GET ( "/oauth/login/:provider" , h . oauthLogin )
authGroup . GET ( "/oauth/callback/:provider" , h . oauthCallback )
2026-01-30 08:46:24 +08:00
2025-11-05 20:11:23 +08:00
// Token refresh endpoint - generates new access token using refresh token
2026-02-02 20:29:10 +08:00
authGroup . POST ( "/token/refresh" , h . refreshToken )
2026-02-17 11:59:18 +08:00
authGroup . POST ( "/refresh" , h . refreshToken )
2025-10-07 09:41:27 +08:00
2026-02-05 09:37:04 +08:00
authGroup . GET ( "/mfa/status" , h . mfaStatus )
2026-02-17 11:59:18 +08:00
authGroup . GET ( "/sync/config" , h . syncConfigSnapshot )
authGroup . POST ( "/sync/ack" , h . syncConfigAck )
2026-03-18 15:14:08 +08:00
authGroup . GET ( "/homepage-video" , h . getHomepageVideoPublic )
2026-02-05 09:37:04 +08:00
2026-02-07 02:23:53 +08:00
// Sandbox binding read endpoint.
// Used by the Console Guest/Demo experience. Must be readable either via a
// normal user session or via the internal service token.
authGroup . GET ( "/sandbox/binding" , h . getSandboxBindingPublic )
2025-11-05 20:11:23 +08:00
// Protected routes requiring authentication
2026-02-02 20:29:10 +08:00
authProtected := authGroup . Group ( "" )
2025-11-05 20:11:23 +08:00
if h . tokenService != nil {
authProtected . Use ( h . tokenService . AuthMiddleware ( ) )
2026-02-02 20:19:06 +08:00
authProtected . Use ( auth . RequireActiveUser ( h . store ) )
2025-11-05 20:11:23 +08:00
}
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 )
2026-03-17 13:24:41 +08:00
authProtected . GET ( "/xworkmate/profile" , h . getXWorkmateProfile )
2026-04-13 19:28:25 +08:00
authProtected . GET ( "/xworkmate/profile/sync" , h . getXWorkmateProfileSync )
2026-03-17 13:24:41 +08:00
authProtected . PUT ( "/xworkmate/profile" , h . updateXWorkmateProfile )
2026-04-02 09:14:19 +08:00
authProtected . GET ( "/xworkmate/secrets" , h . getXWorkmateSecrets )
authProtected . PUT ( "/xworkmate/secrets/:target" , h . putXWorkmateSecret )
authProtected . DELETE ( "/xworkmate/secrets/:target" , h . deleteXWorkmateSecret )
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 )
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 )
2026-03-16 20:09:58 +08:00
authProtected . POST ( "/stripe/checkout" , h . stripeCheckout )
authProtected . POST ( "/stripe/portal" , h . stripePortal )
2025-11-21 18:55:12 +08:00
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 )
2026-03-18 15:14:08 +08:00
authProtected . GET ( "/admin/homepage-video" , h . getHomepageVideoSettings )
authProtected . PUT ( "/admin/homepage-video" , h . updateHomepageVideoSettings )
2025-11-05 20:11:23 +08:00
2026-02-05 15:01:12 +08:00
// Backward-compatible auth-scoped admin routes consumed by the dashboard BFF.
authProtected . GET ( "/admin/users/metrics" , h . adminUsersMetrics )
authProtected . POST ( "/admin/users" , h . createCustomUser )
2026-01-30 08:59:55 +08:00
authProtected . POST ( "/admin/users/:userId/role" , h . updateUserRole )
authProtected . DELETE ( "/admin/users/:userId/role" , h . resetUserRole )
2026-02-05 15:01:12 +08:00
authProtected . POST ( "/admin/users/:userId/pause" , h . pauseUser )
authProtected . POST ( "/admin/users/:userId/resume" , h . resumeUser )
authProtected . DELETE ( "/admin/users/:userId" , h . deleteUser )
authProtected . POST ( "/admin/users/:userId/renew-uuid" , h . renewProxyUUID )
2026-03-17 13:24:41 +08:00
authProtected . POST ( "/admin/tenants/bootstrap" , h . bootstrapTenant )
2026-02-05 15:01:12 +08:00
authProtected . GET ( "/admin/blacklist" , h . listBlacklist )
authProtected . POST ( "/admin/blacklist" , h . addToBlacklist )
authProtected . DELETE ( "/admin/blacklist/:email" , h . removeFromBlacklist )
2026-02-06 18:06:20 +08:00
// Sandbox node binding (root-only via permissions guard).
authProtected . GET ( "/admin/sandbox/binding" , h . getSandboxBinding )
authProtected . POST ( "/admin/sandbox/bind" , h . bindSandboxNode )
// Root-only identity switch to sandbox@svc.plus (hard-coded allowlist).
authProtected . POST ( "/admin/assume" , h . adminAssume )
authProtected . POST ( "/admin/assume/revert" , h . adminAssumeRevert )
authProtected . GET ( "/admin/assume/status" , h . adminAssumeStatus )
2026-02-05 15:01:12 +08:00
authProtected . GET ( "/users" , h . listUsers )
2026-01-30 08:59:55 +08:00
2026-02-04 14:15:27 +08:00
// Internal routes for service-to-service reads.
internalGroup := r . Group ( "/api/internal" )
2026-03-16 20:09:58 +08:00
r . POST ( "/api/billing/stripe/webhook" , h . stripeWebhook )
2026-02-04 14:15:27 +08:00
internalGroup . Use ( auth . InternalAuthMiddleware ( ) )
internalGroup . GET ( "/public-overview" , h . internalPublicOverview )
2026-02-07 02:31:52 +08:00
internalGroup . GET ( "/sandbox/guest" , h . internalSandboxGuest )
2026-04-09 13:29:18 +08:00
internalGroup . GET ( "/network/identities" , h . internalNetworkIdentities )
2026-04-01 16:15:16 +08:00
internalGroup . GET ( "/policy/:accountUUID" , h . internalAccountPolicy )
internalGroup . POST ( "/nodes/heartbeat" , h . internalNodeHeartbeat )
2026-02-04 14:15:27 +08:00
2026-02-02 21:07:10 +08:00
// Public /api routes for admin/management (expected by frontend at /api/admin/...)
apiGroup := r . Group ( "/api" )
2026-01-31 17:42:05 +08:00
if h . tokenService != nil {
2026-02-02 21:07:10 +08:00
apiGroup . Use ( h . tokenService . AuthMiddleware ( ) )
apiGroup . Use ( auth . RequireActiveUser ( h . store ) )
2026-01-31 17:42:05 +08:00
}
2026-02-02 21:07:10 +08:00
registerAdminRoutes ( apiGroup , h )
2026-01-31 17:42:05 +08:00
2026-02-05 17:39:43 +08:00
// Canonical user-facing agent routes.
// These endpoints use session-based auth in handler logic and intentionally
// stay outside token middleware to support dashboard session tokens.
agentServerGroup := r . Group ( "/api/agent-server/v1" )
2026-02-05 16:52:10 +08:00
agentServerGroup . GET ( "/nodes" , h . listAgentNodes )
2026-02-06 18:06:20 +08:00
agentServerGroup . GET ( "/users" , h . listAgentUsers )
agentServerGroup . POST ( "/status" , h . reportAgentStatus )
2026-02-05 16:52:10 +08:00
2026-04-01 16:15:16 +08:00
accountGroup := r . Group ( "/api/account" )
accountGroup . GET ( "/usage/summary" , h . accountUsageSummary )
accountGroup . GET ( "/usage/buckets" , h . accountUsageBuckets )
accountGroup . GET ( "/billing/summary" , h . accountBillingSummary )
accountGroup . GET ( "/policy" , h . accountPolicy )
2026-02-05 17:39:43 +08:00
// Legacy alias kept for backward compatibility.
agentGroup := r . Group ( "/api/agent" )
2026-02-02 21:07:10 +08:00
agentGroup . GET ( "/nodes" , h . listAgentNodes )
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" `
2026-02-21 07:30:38 +08:00
Account string ` json:"account" `
2025-10-02 14:06:07 +08:00
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
}
2026-02-02 20:19:06 +08:00
blacklisted , err := h . store . IsBlacklisted ( c . Request . Context ( ) , email )
if err != nil {
respondError ( c , http . StatusInternalServerError , "blacklist_check_failed" , "failed to verify email status" )
return
}
if blacklisted {
respondError ( c , http . StatusForbidden , "email_blacklisted" , "this email address is blocked" )
return
}
2025-09-30 18:42:10 +08:00
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 . 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
}
2026-04-23 23:13:10 +08:00
if ! h . emailVerificationEnabled {
c . JSON ( http . StatusOK , gin . H { "message" : "verification email sent" } )
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
}
2026-02-02 20:19:06 +08:00
blacklisted , err := h . store . IsBlacklisted ( c . Request . Context ( ) , email )
if err != nil {
respondError ( c , http . StatusInternalServerError , "blacklist_check_failed" , "failed to verify email status" )
return
}
if blacklisted {
respondError ( c , http . StatusForbidden , "email_blacklisted" , "this email address is blocked" )
return
}
2025-11-04 09:38:15 +08:00
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
}
2026-02-04 12:37:31 +08:00
if h . isReadOnlyAccount ( user ) {
respondError ( c , http . StatusForbidden , "read_only_account" , "demo account cannot change password" )
return
}
2025-10-02 17:56:30 +08:00
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
}
2026-02-04 12:37:31 +08:00
if h . isReadOnlyAccount ( user ) {
h . removePasswordReset ( token )
respondError ( c , http . StatusForbidden , "read_only_account" , "demo account cannot change password" )
return
}
2025-10-02 17:56:30 +08:00
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
} )
}
2026-02-04 13:36:24 +08:00
var allowedPermissionMatrixRoles = map [ string ] struct { } {
store . RoleRoot : { } ,
store . RoleOperator : { } ,
store . RoleUser : { } ,
store . RoleReadOnly : { } ,
store . RoleAdmin : { } ,
}
var assignableUserRoles = map [ string ] struct { } {
store . RoleOperator : { } ,
store . RoleUser : { } ,
store . RoleReadOnly : { } ,
2025-10-07 09:24:00 +08:00
}
func ( h * handler ) getAdminSettings ( c * gin . Context ) {
2026-02-04 13:36:24 +08:00
if _ , ok := h . requireAdminPermission ( c , permissionAdminSettingsRead ) ; ! 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 ) {
2026-02-04 13:36:24 +08:00
adminUser , ok := h . requireAdminPermission ( c , permissionAdminSettingsWrite )
2026-02-04 12:37:31 +08:00
if ! ok {
return
}
if h . isReadOnlyAccount ( adminUser ) {
respondError ( c , http . StatusForbidden , "read_only_account" , "demo account is read-only" )
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" )
}
2026-02-04 13:36:24 +08:00
if _ , ok := allowedPermissionMatrixRoles [ key ] ; ! ok {
2025-10-07 09:24:00 +08:00
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 )
2026-02-21 07:30:38 +08:00
if identifier == "" {
identifier = strings . TrimSpace ( req . Account )
}
2025-10-02 14:06:07 +08:00
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
}
2026-02-06 18:06:20 +08:00
// Sandbox user is not allowed to login by password/totp.
// Root can only assume into sandbox via the admin assume endpoint.
if strings . EqualFold ( strings . TrimSpace ( user . Email ) , sandboxUserEmail ) {
respondError ( c , http . StatusForbidden , "sandbox_no_login" , "sandbox login is disabled" )
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
}
2026-02-04 14:59:19 +08:00
// Demo/read-only account explicitly disables MFA to keep the roaming
// experience simple while write operations remain blocked by policy.
if h . isReadOnlyAccount ( user ) {
if user . MFAEnabled || strings . TrimSpace ( user . MFATOTPSecret ) != "" || ! user . MFASecretIssuedAt . IsZero ( ) || ! user . MFAConfirmedAt . IsZero ( ) {
user . MFATOTPSecret = ""
user . MFAEnabled = false
user . MFASecretIssuedAt = time . Time { }
user . MFAConfirmedAt = time . Time { }
if err := h . store . UpdateUser ( c . Request . Context ( ) , user ) ; err != nil {
slog . Warn ( "failed to reset mfa state for read-only account" , "err" , err , "userID" , user . ID )
}
}
}
2025-10-05 08:00:16 +08:00
if user . MFAEnabled {
if totpCode == "" {
2026-02-17 11:59:18 +08:00
mfaTicket , err := h . createMFAChallenge ( user . ID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "mfa_challenge_creation_failed" , "failed to create mfa challenge" )
return
}
c . JSON ( http . StatusOK , gin . H {
"message" : "mfa required" ,
"mfaRequired" : true ,
"mfa_required" : true ,
"mfaMethod" : "totp" ,
"mfa_method" : "totp" ,
"mfaTicket" : mfaTicket ,
"mfa_ticket" : mfaTicket ,
// Kept for backward compatibility with existing clients.
"mfaToken" : mfaTicket ,
} )
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 {
2026-02-17 11:59:18 +08:00
"message" : "login successful" ,
"token" : token ,
"access_token" : token ,
"expiresAt" : expiresAt . UTC ( ) ,
"expires_in" : int64 ( time . Until ( expiresAt ) . Seconds ( ) ) ,
"mfaRequired" : false ,
"mfa_required" : false ,
"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 {
2026-02-17 11:59:18 +08:00
"message" : "login successful" ,
"token" : token ,
"access_token" : token ,
"expiresAt" : expiresAt . UTC ( ) ,
"expires_in" : int64 ( time . Until ( expiresAt ) . Seconds ( ) ) ,
"mfaRequired" : false ,
"mfa_required" : false ,
"user" : sanitizeUser ( user , nil ) ,
2025-10-05 08:00:16 +08:00
}
2026-02-04 14:59:19 +08:00
if ! h . isReadOnlyAccount ( user ) {
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
}
2025-10-05 08:00:16 +08:00
}
c . JSON ( http . StatusOK , response )
2025-08-19 12:49:05 +08:00
}
2025-09-30 18:42:10 +08:00
2026-02-17 11:59:18 +08:00
func ( h * handler ) verifyMFALogin ( c * gin . Context ) {
var req struct {
MFATicket string ` json:"mfa_ticket" `
MFAToken string ` json:"mfaToken" `
Code string ` json:"code" `
TOTPCode string ` json:"totpCode" `
Method string ` json:"method" `
}
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
mfaTicket := strings . TrimSpace ( req . MFATicket )
if mfaTicket == "" {
mfaTicket = strings . TrimSpace ( req . MFAToken )
}
if mfaTicket == "" {
respondError ( c , http . StatusBadRequest , "mfa_ticket_required" , "mfa ticket is required" )
return
}
code := strings . TrimSpace ( req . Code )
if code == "" {
code = strings . TrimSpace ( req . TOTPCode )
}
if code == "" {
respondError ( c , http . StatusBadRequest , "mfa_code_required" , "totp code is required" )
return
}
method := strings . ToLower ( strings . TrimSpace ( req . Method ) )
if method == "" {
method = "totp"
}
if method != "totp" {
respondError ( c , http . StatusBadRequest , "unsupported_mfa_method" , "unsupported mfa method" )
return
}
challenge , ok := h . lookupMFAChallenge ( mfaTicket )
if ! ok {
respondError ( c , http . StatusUnauthorized , "invalid_mfa_ticket" , "mfa ticket is invalid or expired" )
return
}
user , err := h . store . GetUserByID ( c . Request . Context ( ) , challenge . userID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "authentication_failed" , "failed to authenticate user" )
return
}
if ! user . MFAEnabled {
respondError ( c , http . StatusBadRequest , "mfa_not_enabled" , "multi-factor authentication is not enabled" )
return
}
valid , err := totp . ValidateCustom ( code , user . MFATOTPSecret , time . Now ( ) . UTC ( ) , totp . ValidateOpts {
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 {
respondError ( c , http . StatusUnauthorized , "invalid_mfa_code" , "invalid totp code" )
return
}
h . removeMFAChallenge ( mfaTicket )
token , expiresAt , err := h . createSession ( user . ID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "session_creation_failed" , "failed to create session" )
return
}
h . setSessionCookie ( c , token , expiresAt )
c . JSON ( http . StatusOK , gin . H {
"message" : "login successful" ,
"token" : token ,
"access_token" : token ,
"expiresAt" : expiresAt . UTC ( ) ,
"expires_in" : int64 ( time . Until ( expiresAt ) . Seconds ( ) ) ,
"mfaRequired" : false ,
"mfa_required" : false ,
"user" : sanitizeUser ( user , nil ) ,
} )
}
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 {
2026-03-17 08:51:01 +08:00
ExchangeCode string ` json:"exchange_code" `
2025-11-05 20:11:23 +08:00
}
func ( h * handler ) exchangeToken ( c * gin . Context ) {
var req tokenExchangeRequest
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
2026-03-17 08:51:01 +08:00
sessionToken , _ , ok := h . consumeOAuthExchangeCode ( req . ExchangeCode )
if ! ok {
respondError ( c , http . StatusUnauthorized , "invalid_exchange_code" , "invalid or expired exchange code" )
2025-11-05 20:11:23 +08:00
return
}
2026-03-17 08:51:01 +08:00
sess , ok := h . lookupSession ( sessionToken )
if ! ok {
respondError ( c , http . StatusUnauthorized , "invalid_exchange_code" , "exchange session is invalid or expired" )
return
2025-11-05 20:11:23 +08:00
}
2026-03-17 08:51:01 +08:00
user , err := h . store . GetUserByID ( c . Request . Context ( ) , sess . userID )
2025-11-05 20:11:23 +08:00
if err != nil {
2026-03-17 08:51:01 +08:00
respondError ( c , http . StatusInternalServerError , "session_user_lookup_failed" , "failed to load session user" )
2025-11-05 20:11:23 +08:00
return
}
2026-03-17 08:51:01 +08:00
expiresIn := int64 ( time . Until ( sess . expiresAt ) . Seconds ( ) )
if expiresIn < 0 {
expiresIn = 0
}
2025-11-05 20:11:23 +08:00
c . JSON ( http . StatusOK , gin . H {
2026-03-17 08:51:01 +08:00
"token" : sessionToken ,
"access_token" : sessionToken ,
"token_type" : "Bearer" ,
"expiresAt" : sess . expiresAt . UTC ( ) ,
"expires_in" : expiresIn ,
"user" : sanitizeUser ( user , nil ) ,
2025-11-05 20:11:23 +08:00
} )
}
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
}
2026-02-06 18:06:20 +08:00
// Sandbox UUID rotates hourly; refresh on session reads so the UI always sees a valid UUID.
if err := h . ensureSandboxProxyUUID ( c . Request . Context ( ) , user ) ; err != nil {
slog . Warn ( "failed to rotate sandbox proxy uuid" , "err" , err , "userID" , user . ID )
}
2026-03-17 13:24:41 +08:00
sanitized , err := h . buildSessionUser ( c . Request . Context ( ) , h . resolveTenantHost ( c ) , user )
if err != nil {
if errors . Is ( err , store . ErrTenantNotFound ) {
c . JSON ( http . StatusOK , gin . H { "user" : sanitizeUser ( user , nil ) } )
return
}
respondError ( c , http . StatusInternalServerError , "session_tenant_resolution_failed" , "failed to resolve tenant session context" )
return
}
c . JSON ( http . StatusOK , gin . H { "user" : sanitized } )
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
2026-02-05 09:37:04 +08:00
if err := h . store . CreateSession ( context . Background ( ) , token , userID , expiresAt ) ; err != nil {
return "" , time . Time { } , err
}
2025-09-30 18:42:10 +08:00
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 )
2026-02-06 22:59:46 +08:00
domain := h . getCookieDomain ( )
c . SetCookie ( sessionCookieName , token , maxAge , "/" , domain , secure , true )
}
func ( h * handler ) getCookieDomain ( ) string {
if h . publicURL == "" {
return ""
}
u , err := url . Parse ( h . publicURL )
if err != nil {
return ""
}
host := strings . Split ( u . Hostname ( ) , ":" ) [ 0 ]
if host == "localhost" || host == "127.0.0.1" {
return ""
}
parts := strings . Split ( host , "." )
if len ( parts ) >= 2 {
return "." + strings . Join ( parts [ len ( parts ) - 2 : ] , "." )
}
return ""
2025-10-06 15:43:25 +08:00
}
2025-09-30 18:42:10 +08:00
func ( h * handler ) lookupSession ( token string ) ( session , bool ) {
2026-02-05 09:37:04 +08:00
userID , expiresAt , err := h . store . GetSession ( context . Background ( ) , token )
if err != nil {
2025-09-30 18:42:10 +08:00
return session { } , false
}
2026-02-05 09:37:04 +08:00
return session { userID : userID , expiresAt : expiresAt } , true
2025-09-30 18:42:10 +08:00
}
func ( h * handler ) removeSession ( token string ) {
2026-02-05 09:37:04 +08:00
h . store . DeleteSession ( context . Background ( ) , token )
2025-09-30 18:42:10 +08:00
}
2026-03-17 08:51:01 +08:00
func ( h * handler ) issueOAuthExchangeCode ( sessionToken string , sessionExpiresAt time . Time ) ( string , time . Time , error ) {
code , err := h . newRandomToken ( )
if err != nil {
return "" , time . Time { } , err
}
expiresAt := time . Now ( ) . Add ( h . oauthExchangeTTL )
if h . oauthExchangeTTL <= 0 {
expiresAt = time . Now ( ) . Add ( defaultOAuthExchangeCodeTTL )
}
if ! sessionExpiresAt . IsZero ( ) && sessionExpiresAt . Before ( expiresAt ) {
expiresAt = sessionExpiresAt
}
h . oauthExchangeMu . Lock ( )
defer h . oauthExchangeMu . Unlock ( )
h . oauthExchangeCodes [ code ] = oauthExchangeCode {
sessionToken : sessionToken ,
sessionExpiresAt : sessionExpiresAt ,
expiresAt : expiresAt ,
}
return code , expiresAt , nil
}
func ( h * handler ) consumeOAuthExchangeCode ( code string ) ( string , time . Time , bool ) {
normalized := strings . TrimSpace ( code )
if normalized == "" {
return "" , time . Time { } , false
}
h . oauthExchangeMu . Lock ( )
defer h . oauthExchangeMu . Unlock ( )
record , ok := h . oauthExchangeCodes [ normalized ]
if ! ok {
return "" , time . Time { } , false
}
delete ( h . oauthExchangeCodes , normalized )
if time . Now ( ) . After ( record . expiresAt ) {
return "" , time . Time { } , false
}
return record . sessionToken , record . sessionExpiresAt , true
}
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" )
}
2026-01-25 14:17:30 +08:00
normalizedEmail := strings . ToLower ( email )
2025-10-02 17:56:30 +08:00
ttl := h . verificationTTL
if ttl <= 0 {
ttl = defaultEmailVerificationTTL
}
h . verificationMu . Lock ( )
2026-01-25 14:17:30 +08:00
var code string
var expiresAt time . Time
if existing , ok := h . verifications [ normalizedEmail ] ; ok && time . Now ( ) . Before ( existing . expiresAt ) {
code = existing . code
expiresAt = existing . expiresAt
} else {
var err error
code , err = h . newVerificationCode ( )
if err != nil {
h . verificationMu . Unlock ( )
return err
}
expiresAt = time . Now ( ) . Add ( ttl )
h . verifications [ normalizedEmail ] = emailVerification {
userID : user . ID ,
email : normalizedEmail ,
code : code ,
expiresAt : expiresAt ,
}
}
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 {
2026-01-25 14:17:30 +08:00
// Log but don't delete immediately to allow retries with same code
slog . Error ( "failed to send verification email" , "err" , err , "email" , email )
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
}
h . registrationMu . Lock ( )
2026-01-25 14:17:30 +08:00
var verification registrationVerification
if existing , ok := h . registrationVerifications [ normalized ] ; ok && time . Now ( ) . Before ( existing . expiresAt ) {
verification = existing
} else {
code , err := h . newVerificationCode ( )
if err != nil {
h . registrationMu . Unlock ( )
return registrationVerification { } , err
}
verification = registrationVerification {
email : normalized ,
code : code ,
expiresAt : time . Now ( ) . Add ( ttl ) ,
}
h . registrationVerifications [ normalized ] = verification
}
2025-11-01 21:34:35 +08:00
h . registrationMu . Unlock ( )
2026-01-25 10:12:12 +08:00
// [DEBUG] Log the verification code to stdout so we can see it in logs
2026-01-25 14:17:30 +08:00
slog . Info ( "issued registration verification code" , "email" , normalized , "code" , verification . code )
2026-01-25 10:12:12 +08:00
2025-11-01 21:34:35 +08:00
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" ,
2026-01-25 14:17:30 +08:00
verification . code ,
2025-11-01 21:34:35 +08:00
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>" ,
2026-01-25 14:17:30 +08:00
html . EscapeString ( verification . code ) ,
2025-11-01 21:34:35 +08:00
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 {
2026-01-25 14:17:30 +08:00
// Log but don't delete to allow reuse/resend attempts
slog . Error ( "failed to send registration verification email" , "err" , err , "email" , email )
2025-11-01 21:34:35 +08:00
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
}
2026-02-04 12:37:31 +08:00
if h . isReadOnlyAccount ( user ) {
respondError ( c , http . StatusForbidden , "read_only_account" , "demo account is read-only" )
return
}
2025-10-02 14:06:07 +08:00
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
}
2026-02-04 12:37:31 +08:00
if h . isReadOnlyAccount ( user ) {
respondError ( c , http . StatusForbidden , "read_only_account" , "demo account is read-only" )
return
}
2025-10-02 14:06:07 +08:00
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 ) {
2026-02-06 22:16:28 +08:00
c . JSON ( http . StatusOK , gin . H {
"mfa_enabled" : false ,
} )
2025-10-04 20:37:07 +08:00
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
}
2026-02-04 12:37:31 +08:00
if h . isReadOnlyAccount ( user ) {
respondError ( c , http . StatusForbidden , "read_only_account" , "demo account is read-only" )
return
}
2025-11-21 18:55:12 +08:00
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
}
2026-02-04 12:37:31 +08:00
if h . isReadOnlyAccount ( user ) {
respondError ( c , http . StatusForbidden , "read_only_account" , "demo account is read-only" )
return
}
2025-11-21 18:55:12 +08:00
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
}
2026-03-16 20:09:58 +08:00
if h . stripe != nil && h . stripe . enabled ( ) {
subscriptions , err := h . store . ListSubscriptionsByUser ( c . Request . Context ( ) , user . ID )
if err == nil {
for i := range subscriptions {
subscription := subscriptions [ i ]
if strings . TrimSpace ( subscription . ExternalID ) != externalID {
continue
}
if strings . EqualFold ( strings . TrimSpace ( subscription . Provider ) , "stripe" ) && strings . EqualFold ( strings . TrimSpace ( subscription . Kind ) , "subscription" ) {
if err := h . stripe . cancelSubscription ( c . Request . Context ( ) , externalID ) ; err != nil {
respondError ( c , http . StatusBadGateway , "stripe_cancel_failed" , "failed to cancel stripe subscription" )
return
}
}
break
}
}
}
2025-11-21 18:55:12 +08:00
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 )
2026-02-04 14:59:19 +08:00
proxyUUID := strings . TrimSpace ( user . ProxyUUID )
if proxyUUID == "" {
proxyUUID = identifier
}
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 {
2026-02-04 14:59:19 +08:00
"id" : identifier ,
"uuid" : identifier ,
"name" : user . Name ,
"username" : user . Name ,
"email" : user . Email ,
"emailVerified" : user . EmailVerified ,
"mfaEnabled" : user . MFAEnabled ,
"mfa" : buildMFAState ( user , challenge ) ,
"role" : user . Role ,
"groups" : groups ,
"permissions" : permissions ,
"proxyUuid" : proxyUUID ,
"proxyUuidExpiresAt" : user . ProxyUUIDExpiresAt ,
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
}
2026-02-04 12:37:31 +08:00
if h . isReadOnlyAccount ( user ) {
respondError ( c , http . StatusForbidden , "read_only_account" , "demo account is read-only" )
return
}
2025-10-04 20:38:16 +08:00
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
} )
}
2026-01-30 08:46:24 +08:00
func ( h * handler ) oauthLogin ( c * gin . Context ) {
providerName := c . Param ( "provider" )
provider , ok := h . oauthProviders [ providerName ]
if ! ok {
respondError ( c , http . StatusNotFound , "provider_not_found" , "oauth provider not found" )
return
}
2026-03-17 13:24:41 +08:00
state := buildOAuthState ( h . resolveFrontendURL ( c ) )
2026-01-30 08:46:24 +08:00
// In a real app, we should store state in a secure cookie or session.
// For now, we'll just redirect.
c . Redirect ( http . StatusTemporaryRedirect , provider . AuthCodeURL ( state ) )
}
func ( h * handler ) oauthCallback ( c * gin . Context ) {
providerName := c . Param ( "provider" )
provider , ok := h . oauthProviders [ providerName ]
if ! ok {
respondError ( c , http . StatusNotFound , "provider_not_found" , "oauth provider not found" )
return
}
code := c . Query ( "code" )
if code == "" {
respondError ( c , http . StatusBadRequest , "code_missing" , "oauth code missing" )
return
}
token , err := provider . Exchange ( c . Request . Context ( ) , code )
if err != nil {
respondError ( c , http . StatusInternalServerError , "oauth_exchange_failed" , "failed to exchange oauth code" )
return
}
2026-03-17 08:51:01 +08:00
profile , err := provider . FetchProfile ( c . Request . Context ( ) , token )
2026-01-30 08:46:24 +08:00
if err != nil {
respondError ( c , http . StatusInternalServerError , "fetch_profile_failed" , "failed to fetch user profile" )
return
}
if profile . Email == "" {
respondError ( c , http . StatusBadRequest , "email_missing" , "email not provided by oauth provider" )
return
}
2026-02-02 21:02:32 +08:00
if ! profile . Verified {
respondError ( c , http . StatusUnauthorized , "email_not_verified" , "oauth email must be verified" )
return
}
var user * store . User
2026-01-30 08:46:24 +08:00
ctx := c . Request . Context ( )
2026-02-02 21:02:32 +08:00
existingUser , err := h . store . GetUserByEmail ( ctx , profile . Email )
2026-01-30 08:46:24 +08:00
if err != nil && ! errors . Is ( err , store . ErrUserNotFound ) {
respondError ( c , http . StatusInternalServerError , "store_error" , "database error" )
return
}
if errors . Is ( err , store . ErrUserNotFound ) {
// Auto-register user
user = & store . User {
Name : profile . Name ,
Email : profile . Email ,
2026-02-02 21:02:32 +08:00
EmailVerified : true , // Trusted provider, verified above
2026-01-30 08:46:24 +08:00
Level : store . LevelUser ,
Role : store . RoleUser ,
Groups : [ ] string { "User" } ,
2026-02-02 21:02:32 +08:00
Active : true ,
2026-01-30 08:46:24 +08:00
}
if err := h . store . CreateUser ( ctx , user ) ; err != nil {
respondError ( c , http . StatusInternalServerError , "user_creation_failed" , "failed to create user" )
return
}
// Provision trial
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 { "expiresAt" : trialExpiresAt } ,
}
h . store . UpsertSubscription ( ctx , trial )
2026-02-02 21:02:32 +08:00
} else {
user = existingUser
// Ensure user is verified if they logged in via OAuth
if ! user . EmailVerified {
user . EmailVerified = true
if err := h . store . UpdateUser ( ctx , user ) ; err != nil {
slog . Warn ( "failed to update user verification status during oauth" , "err" , err , "userID" , user . ID )
}
}
}
// Always ensure identity record exists (bind OAuth ID to User Email)
identity := & store . Identity {
UserID : user . ID ,
Provider : providerName ,
ExternalID : profile . ID ,
}
if err := h . store . CreateIdentity ( ctx , identity ) ; err != nil {
// Only log error if it's not a "already exists" error
if ! strings . Contains ( err . Error ( ) , "exists" ) {
slog . Warn ( "failed to create identity record during oauth binding" , "err" , err , "userID" , user . ID )
}
2026-01-30 08:46:24 +08:00
}
2026-03-17 08:51:01 +08:00
sessionToken , sessionExpiresAt , err := h . createSession ( user . ID )
if err != nil {
respondError ( c , http . StatusInternalServerError , "session_creation_failed" , "failed to create session" )
2026-01-30 08:46:24 +08:00
return
}
2026-03-17 08:51:01 +08:00
exchangeCode , _ , err := h . issueOAuthExchangeCode ( sessionToken , sessionExpiresAt )
if err != nil {
respondError ( c , http . StatusInternalServerError , "exchange_code_creation_failed" , "failed to issue exchange code" )
return
}
2026-01-30 08:46:24 +08:00
2026-03-17 13:24:41 +08:00
frontendURL := h . validateFrontendURL ( parseOAuthStateFrontendURL ( c . Query ( "state" ) ) )
if frontendURL == "" {
frontendURL = h . resolveFrontendURL ( c )
}
if frontendURL == "" {
frontendURL = h . oauthFrontendURL
}
2026-01-30 08:46:24 +08:00
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
2026-03-17 08:51:01 +08:00
targetURL := fmt . Sprintf ( "%s/login?exchange_code=%s" ,
2026-01-30 23:12:01 +08:00
strings . TrimSuffix ( frontendURL , "/" ) ,
2026-03-17 08:51:01 +08:00
url . QueryEscape ( exchangeCode ) )
2026-01-30 08:46:24 +08:00
c . Redirect ( http . StatusTemporaryRedirect , targetURL )
}
2026-01-30 08:59:55 +08:00
func ( h * handler ) listUsers ( c * gin . Context ) {
2026-02-04 13:36:24 +08:00
if _ , ok := h . requireAdminPermission ( c , permissionAdminUsersListRead ) ; ! ok {
2026-01-30 08:59:55 +08:00
return
}
users , err := h . store . ListUsers ( c . Request . Context ( ) )
if err != nil {
respondError ( c , http . StatusInternalServerError , "list_users_failed" , "failed to fetch users" )
return
}
sanitized := make ( [ ] gin . H , 0 , len ( users ) )
for _ , u := range users {
sanitized = append ( sanitized , sanitizeUser ( & u , nil ) )
}
c . JSON ( http . StatusOK , sanitized )
}
func ( h * handler ) updateUserRole ( c * gin . Context ) {
2026-02-04 13:36:24 +08:00
if _ , ok := h . requireAdminPermission ( c , permissionAdminUsersRoleWrite ) ; ! ok {
2026-01-30 08:59:55 +08:00
return
}
userId := c . Param ( "userId" )
if userId == "" {
respondError ( c , http . StatusBadRequest , "userId_required" , "userId is required" )
return
}
var req struct {
Role string ` json:"role" `
}
if err := c . ShouldBindJSON ( & req ) ; err != nil {
respondError ( c , http . StatusBadRequest , "invalid_request" , "invalid request payload" )
return
}
role := strings . ToLower ( strings . TrimSpace ( req . Role ) )
2026-02-04 13:36:24 +08:00
if _ , ok := assignableUserRoles [ role ] ; ! ok {
2026-01-30 08:59:55 +08:00
respondError ( c , http . StatusBadRequest , "invalid_role" , "specified role is not allowed" )
return
}
user , err := h . store . GetUserByID ( c . Request . Context ( ) , userId )
if err != nil {
if errors . Is ( err , store . ErrUserNotFound ) {
respondError ( c , http . StatusNotFound , "user_not_found" , "user not found" )
return
}
respondError ( c , http . StatusInternalServerError , "update_failed" , "failed to fetch user" )
return
}
2026-02-04 13:36:24 +08:00
if h . isRootAccount ( user ) {
respondError ( c , http . StatusForbidden , "root_protected" , "root account role cannot be modified" )
return
}
2026-01-30 08:59:55 +08:00
user . Role = role
// Role field update will trigger Level update in store if implemented according to plan
// In store.go, normalizeUserRoleFields handles it.
if err := h . store . UpdateUser ( c . Request . Context ( ) , user ) ; err != nil {
respondError ( c , http . StatusInternalServerError , "update_failed" , "failed to update user" )
return
}
c . JSON ( http . StatusOK , gin . H { "message" : "role updated" , "user" : sanitizeUser ( user , nil ) } )
}
func ( h * handler ) resetUserRole ( c * gin . Context ) {
2026-02-04 13:36:24 +08:00
if _ , ok := h . requireAdminPermission ( c , permissionAdminUsersRoleWrite ) ; ! ok {
2026-01-30 08:59:55 +08:00
return
}
userId := c . Param ( "userId" )
if userId == "" {
respondError ( c , http . StatusBadRequest , "userId_required" , "userId is required" )
return
}
user , err := h . store . GetUserByID ( c . Request . Context ( ) , userId )
if err != nil {
if errors . Is ( err , store . ErrUserNotFound ) {
respondError ( c , http . StatusNotFound , "user_not_found" , "user not found" )
return
}
respondError ( c , http . StatusInternalServerError , "update_failed" , "failed to fetch user" )
return
}
2026-02-04 13:36:24 +08:00
if h . isRootAccount ( user ) {
respondError ( c , http . StatusForbidden , "root_protected" , "root account role cannot be modified" )
return
}
2026-01-30 08:59:55 +08:00
user . Role = store . RoleUser
if err := h . store . UpdateUser ( c . Request . Context ( ) , user ) ; err != nil {
respondError ( c , http . StatusInternalServerError , "update_failed" , "failed to update user" )
return
}
c . JSON ( http . StatusOK , gin . H { "message" : "role reset" , "user" : sanitizeUser ( user , nil ) } )
}
2026-01-30 08:46:24 +08:00
func ( h * handler ) generateState ( ) string {
b := make ( [ ] byte , 16 )
rand . Read ( b )
return hex . EncodeToString ( b )
}
2026-02-04 12:37:31 +08:00
func ( h * handler ) isReadOnlyAccount ( user * store . User ) bool {
if user == nil {
return false
}
2026-02-06 23:20:27 +08:00
// Hardcoded whitelist for admin@svc.plus to bypass read-only checks if they have admin role
email := strings . ToLower ( strings . TrimSpace ( user . Email ) )
if email == "admin@svc.plus" {
return false
2026-02-04 13:36:24 +08:00
}
2026-02-06 23:20:27 +08:00
// Root/SuperAdmin is never read-only (unless we want to enforce it for everyone else)
if isRootUser ( user ) {
return false
}
// Explicit Read-Only Roles/Groups
if strings . EqualFold ( strings . TrimSpace ( user . Role ) , store . RoleReadOnly ) {
2026-02-04 12:37:31 +08:00
return true
}
for _ , group := range user . Groups {
if strings . EqualFold ( strings . TrimSpace ( group ) , "ReadOnly Role" ) {
return true
}
}
2026-02-06 23:20:27 +08:00
2026-02-10 11:52:45 +08:00
// Standard Sandbox users are always read-only
2026-02-06 23:20:27 +08:00
name := strings . TrimSpace ( user . Name )
2026-02-10 11:52:45 +08:00
if strings . EqualFold ( name , "sandbox" ) ||
2026-02-06 23:20:27 +08:00
strings . EqualFold ( email , sandboxUserEmail ) {
return true
}
2026-02-07 02:23:53 +08:00
// Default policy: Allow modification for regular users.
// We only restrict explicitly flagged "demo" or "sandbox" identities or users assigned to a specific "ReadOnly Role".
return false
2026-02-06 23:20:27 +08:00
}
func isRootUser ( user * store . User ) bool {
// Use store.LevelAdmin as the threshold since LevelSuperAdmin is not defined
// and RoleRoot/RoleAdmin identify admin privileges.
return user . Level <= store . LevelAdmin || strings . EqualFold ( user . Role , store . RoleRoot ) || strings . EqualFold ( user . Role , store . RoleAdmin )
2026-02-04 12:37:31 +08:00
}
2026-02-04 13:36:24 +08:00
func ( h * handler ) isRootAccount ( user * store . User ) bool {
if user == nil {
return false
}
return store . IsRootRole ( user . Role ) && strings . EqualFold ( strings . TrimSpace ( user . Email ) , store . RootAdminEmail )
}
2026-04-12 13:42:48 +08:00
func parseImageVersionInfo ( imageRef string ) imageVersionInfo {
ref := strings . TrimSpace ( imageRef )
info := imageVersionInfo { ImageRef : ref }
if ref == "" {
return info
}
if idx := strings . LastIndex ( ref , "@" ) ; idx >= 0 {
ref = ref [ : idx ]
}
tag := ref
if idx := strings . LastIndex ( tag , ":" ) ; idx >= 0 && idx > strings . LastIndex ( tag , "/" ) {
tag = tag [ idx + 1 : ]
}
tag = strings . TrimSpace ( tag )
info . Tag = tag
switch {
case isHexCommit ( tag ) :
info . Commit = tag
info . Version = tag
2026-04-12 14:17:51 +08:00
case strings . HasPrefix ( tag , "sha-" ) && isHexCommit ( strings . TrimPrefix ( tag , "sha-" ) ) :
info . Commit = strings . TrimPrefix ( tag , "sha-" )
info . Version = tag
2026-04-12 13:42:48 +08:00
case strings . HasPrefix ( tag , "v" ) && len ( tag ) > 1 :
info . Version = tag
default :
info . Version = tag
}
return info
}
func isHexCommit ( value string ) bool {
if len ( value ) < 7 || len ( value ) > 40 {
return false
}
for _ , r := range value {
switch {
case r >= '0' && r <= '9' :
case r >= 'a' && r <= 'f' :
default :
return false
}
}
return true
}
2025-10-01 07:52:39 +08:00
func respondError ( c * gin . Context , status int , code , message string ) {
2026-02-06 22:16:28 +08:00
if status >= 500 {
slog . Error ( "api_error" , "status" , status , "code" , code , "message" , message , "path" , c . Request . URL . Path , "method" , c . Request . Method )
}
2025-10-01 07:52:39 +08:00
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 )
}