package api import ( "encoding/json" "net/http" "strings" "time" "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) type changePasswordRequest struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` TOTPCode string `json:"totp_code"` } type totpBeginRequest struct { CurrentPassword string `json:"current_password"` TOTPCode string `json:"totp_code"` } type totpConfirmRequest struct { Secret string `json:"secret"` Code string `json:"code"` CurrentPassword string `json:"current_password"` TOTPCode string `json:"totp_code"` } type totpDisableRequest struct { CurrentPassword string `json:"current_password"` TOTPCode string `json:"totp_code"` } func (app *App) reauthHandler(writer http.ResponseWriter, request *http.Request) { user, ok := UserFromContext(request.Context()) if !ok { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) return } session, ok := SessionFromContext(request.Context()) if !ok { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) return } var payload reauthRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } security := effectiveSecurity(settingsPayload.Security) if err := verifyActorCredentials(user, payload.Password, payload.TOTPCode, security); err != nil { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()}) return } if app.Sessions == nil { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "sessions unavailable"}) return } if err := app.Sessions.MarkReauth(session.ID); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) } func (app *App) mePasswordHandler(writer http.ResponseWriter, request *http.Request) { user, ok := UserFromContext(request.Context()) if !ok { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) return } if err := app.requireKey(); err != nil { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) return } var payload changePasswordRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()}) return } if err := auth.ValidatePassword(payload.NewPassword, user.Username); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) return } hash, err := auth.HashPassword(payload.NewPassword) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } store, err := settings.LoadPasswords(app.ConfigDir, app.Key) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } found := false now := time.Now().UTC() for index := range store.Users { if store.Users[index].ID != user.ID { continue } store.Users[index].PasswordHash = hash store.Users[index].PasswordChangedAt = now found = true break } if !found { writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"}) return } if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } if app.Sessions != nil { _ = app.Sessions.InvalidateUserSessions(user.ID) _ = app.Sessions.DestroySession(writer, request) } writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) } func (app *App) meTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) { user, ok := UserFromContext(request.Context()) if !ok { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) return } var payload totpBeginRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()}) return } settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } security := effectiveSecurity(settingsPayload.Security) if !security.TotpEnabled { writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled on this server"}) return } if user.TOTPConfirmed && user.TOTPSecret != "" { writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is already enabled"}) return } key, err := auth.GenerateTOTPSecret(user.Username) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{ Secret: key.Secret(), OTPAuthURL: key.URL(), }) } func (app *App) meTOTPConfirmHandler(writer http.ResponseWriter, request *http.Request) { user, ok := UserFromContext(request.Context()) if !ok { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) return } if err := app.requireKey(); err != nil { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) return } var payload totpConfirmRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()}) return } settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } security := effectiveSecurity(settingsPayload.Security) if !security.TotpEnabled { writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled on this server"}) return } secret := strings.TrimSpace(payload.Secret) if secret == "" { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing secret"}) return } if !auth.VerifyTOTPCode(secret, payload.Code) { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"}) return } store, err := settings.LoadPasswords(app.ConfigDir, app.Key) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } found := false for index := range store.Users { if store.Users[index].ID != user.ID { continue } store.Users[index].TOTPSecret = secret store.Users[index].TOTPConfirmed = true found = true break } if !found { writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"}) return } if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) } func (app *App) meTOTPDisableHandler(writer http.ResponseWriter, request *http.Request) { user, ok := UserFromContext(request.Context()) if !ok { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) return } if err := app.requireKey(); err != nil { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) return } var payload totpDisableRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } if !user.TOTPConfirmed || user.TOTPSecret == "" { writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled"}) return } // Always require password; require current TOTP code when enrolled. okPassword, err := auth.VerifyPassword(payload.CurrentPassword, user.PasswordHash) if err != nil || !okPassword { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"}) return } if !auth.VerifyTOTPCode(user.TOTPSecret, payload.TOTPCode) { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"}) return } store, err := settings.LoadPasswords(app.ConfigDir, app.Key) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } found := false for index := range store.Users { if store.Users[index].ID != user.ID { continue } store.Users[index].TOTPSecret = "" store.Users[index].TOTPConfirmed = false found = true break } if !found { writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"}) return } if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) }