package handlers import "database/sql" import "net/http" import "net/url" import "strings" import "time" import "codit/internal/auth" import "codit/internal/db" import "codit/internal/middleware" import "codit/internal/models" type totpStatusResponse struct { Enabled bool `json:"enabled"` Required bool `json:"required"` SetupRequired bool `json:"setup_required"` VerifyRequired bool `json:"verify_required"` } type totpSetupResponse struct { Secret string `json:"secret"` OTPAuthURL string `json:"otpauth_url"` } type totpEnableRequest struct { OTPCode string `json:"otp_code"` } type totpDisableRequest struct { Password string `json:"password"` OTPCode string `json:"otp_code"` } func (api *API) finishLogin(w http.ResponseWriter, r *http.Request, user models.User, otpCode string) { var ok bool var err error var required bool var sessionVerified bool required, err = api.store(r).UserRequiresTOTP(user.ID) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } user.TOTPEffectiveRequired = required user.TOTPSetupRequired = required && !user.TOTPEnabled ok, err = api.verifyUserTOTPForLogin(r, user, otpCode) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !ok { if strings.TrimSpace(otpCode) == "" { WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": "totp_required", "totp_required": true}) return } WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_totp"}) return } sessionVerified = !user.TOTPEnabled || ok if required && !user.TOTPEnabled { sessionVerified = false } api.issueSession(w, r, user, sessionVerified) WriteJSON(w, http.StatusOK, user) } func (api *API) verifyUserTOTPForLogin(r *http.Request, user models.User, otpCode string) (bool, error) { var item db.UserTOTP var secret string var err error item, err = api.store(r).GetUserTOTP(user.ID) if err != nil { if err == sql.ErrNoRows { return true, nil } return false, err } if !item.Enabled { return true, nil } secret, err = api.decryptSecretPayload(item.SecretEncrypted) if err != nil { return false, err } return auth.ValidateTOTP(otpCode, secret, time.Now().UTC()), nil } func (api *API) GetMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) { var ctxUser models.User var ok bool var item db.UserTOTP var err error var session middleware.SessionInfo var sessionOK bool ctxUser, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } item, err = api.store(r).GetUserTOTP(ctxUser.ID) if err != nil && err != sql.ErrNoRows { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } session, sessionOK = middleware.SessionFromContext(r.Context()) WriteJSON(w, http.StatusOK, totpStatusResponse{ Enabled: err == nil && item.Enabled, Required: ctxUser.TOTPEffectiveRequired, SetupRequired: ctxUser.TOTPEffectiveRequired && !(err == nil && item.Enabled), VerifyRequired: ctxUser.TOTPEffectiveRequired && err == nil && item.Enabled && (!sessionOK || !session.TOTPVerified), }) } func (api *API) SetupMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) { var ctxUser models.User var ok bool var secret string var encrypted string var url string var err error ctxUser, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } secret, err = auth.NewTOTPSecret() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } encrypted, err = api.encryptSecretPayload(secret) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } err = api.store(r).UpsertUserTOTPPending(ctxUser.ID, encrypted) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } url = auth.TOTPProvisioningURL(api.totpIssuer(r), ctxUser.Username, secret) WriteJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, OTPAuthURL: url}) } func (api *API) totpIssuer(r *http.Request) string { var host string // if the site name is set, use it as a totp issuer host = api.siteName() if host != "" { return host } // use the actual access host name as a totp issuer host = cleanTOTPHost(r.Header.Get("X-Forwarded-Host")) if host != "" { return host } host = cleanTOTPHost(r.Host) if host != "" { return host } return api.serverTitle() } func cleanTOTPHost(value string) string { var parts []string var raw string var parsed *url.URL var err error parts = strings.Split(value, ",") if len(parts) <= 0 { return "" } raw = strings.TrimSpace(parts[0]) if raw == "" { return "" } if strings.Contains(raw, "://") { parsed, err = url.Parse(raw) if err == nil && strings.TrimSpace(parsed.Host) != "" { return strings.TrimSpace(parsed.Host) } } if strings.Contains(raw, "/") { parts = strings.Split(raw, "/") raw = strings.TrimSpace(parts[0]) } return raw } func (api *API) EnableMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) { var ctxUser models.User var ok bool var req totpEnableRequest var item db.UserTOTP var encrypted string var secret string var err error ctxUser, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } item, err = api.store(r).GetUserTOTP(ctxUser.ID) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "totp setup required"}) return } encrypted = item.PendingSecretEncrypted if encrypted == "" { encrypted = item.SecretEncrypted } if encrypted == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "totp setup required"}) return } secret, err = api.decryptSecretPayload(encrypted) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !auth.ValidateTOTP(req.OTPCode, secret, time.Now().UTC()) { WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_totp"}) return } err = api.store(r).EnableUserTOTP(ctxUser.ID, encrypted) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } api.markCurrentSessionTOTPVerified(r) WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: true, Required: ctxUser.TOTPEffectiveRequired, SetupRequired: false, VerifyRequired: false}) } func (api *API) VerifyMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) { var ctxUser models.User var ok bool var req totpEnableRequest var verified bool var err error ctxUser, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } verified, err = api.verifyUserTOTPForLogin(r, ctxUser, req.OTPCode) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !verified { WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_totp"}) return } api.markCurrentSessionTOTPVerified(r) WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: true, Required: ctxUser.TOTPEffectiveRequired, SetupRequired: false, VerifyRequired: false}) } func (api *API) markCurrentSessionTOTPVerified(r *http.Request) { var session middleware.SessionInfo var ok bool session, ok = middleware.SessionFromContext(r.Context()) if !ok || session.Token == "" { return } _ = api.store(r).SetSessionTOTPVerified(session.Token) } func (api *API) DisableMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) { var ctxUser models.User var ok bool var req totpDisableRequest var user models.User var storedHash string var err error ctxUser, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } user, storedHash, err = api.store(r).GetUserByUsername(ctxUser.Username) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"}) return } if storedHash != "" && auth.ComparePassword(storedHash, req.Password) != nil { WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid password"}) return } ok, err = api.verifyUserTOTPForLogin(r, user, req.OTPCode) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !ok { WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_totp"}) return } err = api.store(r).DeleteUserTOTP(ctxUser.ID) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: false, Required: ctxUser.TOTPEffectiveRequired, SetupRequired: ctxUser.TOTPEffectiveRequired, VerifyRequired: false}) } func (api *API) ResetUserTOTPAdmin(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } _, err = api.store(r).GetUserByID(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"}) return } err = api.store(r).DeleteUserTOTP(params["id"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) }