275 lines
9.6 KiB
Go
275 lines
9.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha1"
|
|
"crypto/subtle"
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const sessionCookieName = "gobsidian_session"
|
|
|
|
// LoginPage renders the login form
|
|
func (h *Handlers) LoginPage(c *gin.Context) {
|
|
token, _ := c.Get("csrf_token")
|
|
c.HTML(http.StatusOK, "login", gin.H{
|
|
"app_name": h.config.AppName,
|
|
"csrf_token": token,
|
|
"ContentTemplate": "login_content",
|
|
"ScriptsTemplate": "login_scripts",
|
|
"Page": "login",
|
|
})
|
|
}
|
|
|
|
// isAllDigits returns true if s consists only of ASCII digits 0-9
|
|
func isAllDigits(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MFALoginPage shows OTP prompt when MFA is enabled
|
|
func (h *Handlers) MFALoginPage(c *gin.Context) {
|
|
session, _ := h.store.Get(c.Request, sessionCookieName)
|
|
if _, ok := session.Values["mfa_user_id"]; !ok {
|
|
c.Redirect(http.StatusFound, "/editor/login")
|
|
return
|
|
}
|
|
token, _ := c.Get("csrf_token")
|
|
c.HTML(http.StatusOK, "mfa", gin.H{
|
|
"app_name": h.config.AppName,
|
|
"csrf_token": token,
|
|
"ContentTemplate": "mfa_content",
|
|
"ScriptsTemplate": "mfa_scripts",
|
|
"Page": "mfa",
|
|
})
|
|
}
|
|
|
|
// MFALoginVerify verifies OTP and completes login
|
|
func (h *Handlers) MFALoginVerify(c *gin.Context) {
|
|
code := strings.TrimSpace(c.PostForm("code"))
|
|
if len(code) != 6 || !isAllDigits(code) {
|
|
token, _ := c.Get("csrf_token")
|
|
c.HTML(http.StatusUnauthorized, "mfa", gin.H{
|
|
"app_name": h.config.AppName,
|
|
"csrf_token": token,
|
|
"error": "Invalid code format",
|
|
"ContentTemplate": "mfa_content",
|
|
"ScriptsTemplate": "mfa_scripts",
|
|
"Page": "mfa",
|
|
})
|
|
return
|
|
}
|
|
session, _ := h.store.Get(c.Request, sessionCookieName)
|
|
uidAny, ok := session.Values["mfa_user_id"]
|
|
if !ok {
|
|
c.Redirect(http.StatusFound, "/editor/login")
|
|
return
|
|
}
|
|
uid, _ := uidAny.(int64)
|
|
var secret string
|
|
if err := h.authSvc.DB.QueryRow(`SELECT mfa_secret FROM users WHERE id = ?`, uid).Scan(&secret); err != nil || secret == "" {
|
|
c.HTML(http.StatusUnauthorized, "mfa", gin.H{"error": "MFA not enabled", "Page": "mfa", "ContentTemplate": "mfa_content", "ScriptsTemplate": "mfa_scripts", "app_name": h.config.AppName})
|
|
return
|
|
}
|
|
if !verifyTOTP(secret, code, time.Now()) {
|
|
token, _ := c.Get("csrf_token")
|
|
c.HTML(http.StatusUnauthorized, "mfa", gin.H{
|
|
"app_name": h.config.AppName,
|
|
"csrf_token": token,
|
|
"error": "Invalid code",
|
|
"ContentTemplate": "mfa_content",
|
|
"ScriptsTemplate": "mfa_scripts",
|
|
"Page": "mfa",
|
|
})
|
|
return
|
|
}
|
|
// success: set user_id and clear mfa_user_id
|
|
delete(session.Values, "mfa_user_id")
|
|
session.Values["user_id"] = uid
|
|
_ = session.Save(c.Request, c.Writer)
|
|
c.Redirect(http.StatusFound, "/")
|
|
}
|
|
|
|
// ProfileMFASetupPage shows QR and input to verify during enrollment
|
|
func (h *Handlers) ProfileMFASetupPage(c *gin.Context) {
|
|
uidPtr := getUserIDPtr(c)
|
|
if uidPtr == nil {
|
|
c.Redirect(http.StatusFound, "/editor/login")
|
|
return
|
|
}
|
|
// ensure enrollment exists, otherwise create one
|
|
var secret string
|
|
err := h.authSvc.DB.QueryRow(`SELECT secret FROM mfa_enrollments WHERE user_id = ?`, *uidPtr).Scan(&secret)
|
|
if err != nil || secret == "" {
|
|
// create new enrollment
|
|
s, e := generateBase32Secret()
|
|
if e != nil {
|
|
c.HTML(http.StatusInternalServerError, "error", gin.H{"error": "Failed to create enrollment", "Page": "error", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "app_name": h.config.AppName})
|
|
return
|
|
}
|
|
_, _ = h.authSvc.DB.Exec(`INSERT OR REPLACE INTO mfa_enrollments (user_id, secret) VALUES (?, ?)`, *uidPtr, s)
|
|
secret = s
|
|
}
|
|
// Fetch username for label
|
|
var username string
|
|
_ = h.authSvc.DB.QueryRow(`SELECT username FROM users WHERE id = ?`, *uidPtr).Scan(&username)
|
|
issuer := h.config.AppName
|
|
label := url.PathEscape(fmt.Sprintf("%s:%s", issuer, username))
|
|
otpauth := fmt.Sprintf("otpauth://totp/%s?secret=%s&issuer=%s&digits=6&period=30&algorithm=SHA1", label, secret, url.QueryEscape(issuer))
|
|
|
|
// Render simple page (uses base.html shell)
|
|
c.HTML(http.StatusOK, "mfa_setup", gin.H{
|
|
"app_name": h.config.AppName,
|
|
"Secret": secret,
|
|
"OTPAuthURI": otpauth,
|
|
"ContentTemplate": "mfa_setup_content",
|
|
"ScriptsTemplate": "mfa_setup_scripts",
|
|
"Page": "mfa_setup",
|
|
})
|
|
}
|
|
|
|
// ProfileMFASetupVerify finalizes enrollment by verifying a TOTP code and setting mfa_secret
|
|
func (h *Handlers) ProfileMFASetupVerify(c *gin.Context) {
|
|
uidPtr := getUserIDPtr(c)
|
|
if uidPtr == nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
|
return
|
|
}
|
|
code := strings.TrimSpace(c.PostForm("code"))
|
|
if len(code) != 6 || !isAllDigits(code) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid code format"})
|
|
return
|
|
}
|
|
var secret string
|
|
if err := h.authSvc.DB.QueryRow(`SELECT secret FROM mfa_enrollments WHERE user_id = ?`, *uidPtr).Scan(&secret); err != nil || secret == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no enrollment found"})
|
|
return
|
|
}
|
|
if !verifyTOTP(secret, code, time.Now()) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid code"})
|
|
return
|
|
}
|
|
// move secret to users and delete enrollment
|
|
if _, err := h.authSvc.DB.Exec(`UPDATE users SET mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, secret, *uidPtr); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
_, _ = h.authSvc.DB.Exec(`DELETE FROM mfa_enrollments WHERE user_id = ?`, *uidPtr)
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
}
|
|
|
|
// TOTP helpers (SHA1, 30s window, 6 digits)
|
|
func generateBase32Secret() (string, error) {
|
|
// 20 random bytes -> base32 without padding
|
|
b := make([]byte, 20)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
enc := base32.StdEncoding.WithPadding(base32.NoPadding)
|
|
return enc.EncodeToString(b), nil
|
|
}
|
|
|
|
func hotp(secret []byte, counter uint64) string {
|
|
// HMAC-SHA1
|
|
var buf [8]byte
|
|
binary.BigEndian.PutUint64(buf[:], counter)
|
|
mac := hmac.New(sha1.New, secret)
|
|
mac.Write(buf[:])
|
|
sum := mac.Sum(nil)
|
|
// dynamic truncation
|
|
offset := sum[len(sum)-1] & 0x0F
|
|
code := (uint32(sum[offset])&0x7F)<<24 | (uint32(sum[offset+1])&0xFF)<<16 | (uint32(sum[offset+2])&0xFF)<<8 | (uint32(sum[offset+3]) & 0xFF)
|
|
return fmt.Sprintf("%06d", code%1000000)
|
|
}
|
|
|
|
func verifyTOTP(base32Secret, code string, t time.Time) bool {
|
|
enc := base32.StdEncoding.WithPadding(base32.NoPadding)
|
|
key, err := enc.DecodeString(strings.ToUpper(base32Secret))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
timestep := uint64(t.Unix() / 30)
|
|
// allow +/- 1 window
|
|
candidates := []string{
|
|
hotp(key, timestep-1),
|
|
hotp(key, timestep),
|
|
hotp(key, timestep+1),
|
|
}
|
|
for _, c := range candidates {
|
|
if subtle.ConstantTimeCompare([]byte(c), []byte(code)) == 1 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// LoginPost processes the login form
|
|
func (h *Handlers) LoginPost(c *gin.Context) {
|
|
username := c.PostForm("username")
|
|
password := c.PostForm("password")
|
|
|
|
user, err := h.authSvc.Authenticate(username, password)
|
|
if err != nil {
|
|
token, _ := c.Get("csrf_token")
|
|
c.HTML(http.StatusUnauthorized, "login", gin.H{
|
|
"app_name": h.config.AppName,
|
|
"csrf_token": token,
|
|
"error": err.Error(),
|
|
"ContentTemplate": "login_content",
|
|
"ScriptsTemplate": "login_scripts",
|
|
"Page": "login",
|
|
})
|
|
return
|
|
}
|
|
// If user has MFA enabled, require OTP before setting user_id
|
|
if user.MFASecret.Valid && user.MFASecret.String != "" {
|
|
session, _ := h.store.Get(c.Request, sessionCookieName)
|
|
session.Values["mfa_user_id"] = user.ID
|
|
_ = session.Save(c.Request, c.Writer)
|
|
c.Redirect(http.StatusFound, "/editor/mfa")
|
|
return
|
|
}
|
|
|
|
// If admin created an enrollment for this user, force MFA setup after login
|
|
var pending int
|
|
if err := h.authSvc.DB.QueryRow(`SELECT 1 FROM mfa_enrollments WHERE user_id = ?`, user.ID).Scan(&pending); err == nil {
|
|
// normal login, then redirect to setup
|
|
session, _ := h.store.Get(c.Request, sessionCookieName)
|
|
session.Values["user_id"] = user.ID
|
|
_ = session.Save(c.Request, c.Writer)
|
|
c.Redirect(http.StatusFound, "/editor/profile/mfa/setup")
|
|
return
|
|
}
|
|
|
|
// Create normal session
|
|
session, _ := h.store.Get(c.Request, sessionCookieName)
|
|
session.Values["user_id"] = user.ID
|
|
_ = session.Save(c.Request, c.Writer)
|
|
|
|
c.Redirect(http.StatusFound, "/")
|
|
}
|
|
|
|
// LogoutPost clears the session
|
|
func (h *Handlers) LogoutPost(c *gin.Context) {
|
|
session, _ := h.store.Get(c.Request, sessionCookieName)
|
|
session.Options.MaxAge = -1
|
|
_ = session.Save(c.Request, c.Writer)
|
|
c.Redirect(http.StatusFound, "/editor/login")
|
|
}
|