Files
ClusterCanvas/service/internal/api/me_handlers.go
T
Squid 463aa9a7a3 Add activity logs, sidebar badges, and logout with working alert colors.
Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
2026-07-18 21:43:44 +02:00

351 lines
10 KiB
Go

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)
}
appendAuthAudit(
app.ConfigDir,
"password_change",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
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
}
appendAuthAudit(
app.ConfigDir,
"totp_enable",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
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
}
appendAuthAudit(
app.ConfigDir,
"totp_disable",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}