Files

337 lines
9.7 KiB
Go

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)
}