337 lines
9.7 KiB
Go
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)
|
|
}
|