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.
351 lines
10 KiB
Go
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})
|
|
}
|