Files
GL.iNet-Yongping.Xie f49525ee9c feat: add personal center with TOTP 2FA and trusted devices
- Personal Center page with profile editing and last-login info
- TOTP-based two-factor authentication with QR code setup
- 30-day trusted-device cookie to skip 2FA on familiar browsers
- Friendly browser/OS labels for the trusted device list

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-06 21:17:44 -07:00

221 lines
6.0 KiB
Go
Executable File

package handler
import (
"rttys/internal/domain/identity"
"rttys/internal/domain/trusteddevice"
"rttys/internal/pkg/ldap"
"rttys/internal/pkg/totp"
"rttys/internal/pkg/useragent"
"rttys/xconfig"
"strings"
"time"
"rttys/internal/domain/user"
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"rttys/internal/pkg/randtoken"
"rttys/internal/store/memory"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
const (
trustedDeviceCookieName = "td"
trustedDeviceTTL = 30 * 24 * time.Hour
)
type AuthHandler struct {
userSvc *user.Service
sessionStore *memory.SessionStore
trustedDeviceRepo trusteddevice.Repository
}
func NewAuthHandler(userSvc *user.Service, sessionStore *memory.SessionStore, tdRepo trusteddevice.Repository) *AuthHandler {
return &AuthHandler{
userSvc: userSvc,
sessionStore: sessionStore,
trustedDeviceRepo: tdRepo,
}
}
// POST /api/login
func (h *AuthHandler) Login(c *gin.Context) {
traceID := middleware.GetTraceID(c)
var req dto.LoginReq
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{
"field": "username/password",
}))
return
}
cfg := xconfig.Must()
var userID int64
// ---- LDAP ----
authMethod := req.AuthMethod
if authMethod == "ldap" {
ok, errorType, userDN, isAdmin := ldap.AuthenticateUserWithError(cfg, req.Username, req.Password, authMethod)
if !ok {
if errorType == "authorization" {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "User not authorized", nil))
} else {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Authentication failed", nil))
}
return
}
role := identity.RoleUser
if isAdmin {
role = identity.RoleAdmin
}
ldapUser, err := h.userSvc.FindOrCreateExternalUser(c.Request.Context(), "ldap", userDN, req.Username, "", req.Username, role)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to create LDAP user", nil))
return
}
log.Info().
Str("username", req.Username).
Str("userDN", userDN).
Str("role", string(role)).
Int64("userID", ldapUser.ID).
Msg("LDAP user login completed")
userID = ldapUser.ID
} else {
u, err := h.userSvc.Authenticate(c.Request.Context(), req.Username, req.Password)
if err != nil || u == nil {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Authentication failed", nil))
return
}
// Local user with 2FA enabled: enforce TOTP unless a valid trusted-device cookie is present.
if u.TotpEnabled && u.TotpSecret != "" {
if !h.trustedDeviceCookieValid(c, u.ID) {
if strings.TrimSpace(req.TotpCode) == "" {
dto.Write(c, dto.Ok(traceID, dto.LoginResp{TwoFactorRequired: true}))
return
}
if !totp.Verify(u.TotpSecret, strings.TrimSpace(req.TotpCode)) {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Invalid verification code", nil))
return
}
// Optionally remember this device.
if req.RememberDevice {
h.issueTrustedDevice(c, u.ID)
}
}
}
userID = u.ID
}
sid, err := randtoken.New()
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
h.sessionStore.Create(sid, userID)
// Best-effort: refresh last_login_at. Failure here should not block login.
_ = h.userSvc.TouchLastLogin(c.Request.Context(), userID, time.Now().Unix())
dto.Write(c, dto.Ok(traceID, dto.LoginResp{
Token: sid,
}))
}
// trustedDeviceCookieValid returns true if the request carries a non-expired
// trusted-device cookie that maps to the given user. It also refreshes
// last_used_at as a side effect.
func (h *AuthHandler) trustedDeviceCookieValid(c *gin.Context, userID int64) bool {
if h.trustedDeviceRepo == nil {
return false
}
token, err := c.Cookie(trustedDeviceCookieName)
if err != nil || strings.TrimSpace(token) == "" {
return false
}
dev, err := h.trustedDeviceRepo.FindByToken(c.Request.Context(), strings.TrimSpace(token))
if err != nil || dev == nil {
return false
}
if dev.UserID != userID {
return false
}
now := time.Now().Unix()
if dev.ExpiresAt < now {
_ = h.trustedDeviceRepo.Delete(c.Request.Context(), dev.ID, dev.UserID)
return false
}
_ = h.trustedDeviceRepo.TouchLastUsed(c.Request.Context(), dev.ID, now)
return true
}
// issueTrustedDevice creates a new trusted-device record and writes the token cookie.
func (h *AuthHandler) issueTrustedDevice(c *gin.Context, userID int64) {
if h.trustedDeviceRepo == nil {
return
}
token, err := randtoken.New()
if err != nil {
log.Warn().Err(err).Msg("trusted device: generate token failed")
return
}
now := time.Now()
dev := &trusteddevice.Device{
UserID: userID,
Token: token,
DeviceName: trimToLen(useragent.Friendly(c.Request.UserAgent()), 200),
IP: clientIP(c),
CreatedAt: now.Unix(),
LastUsedAt: now.Unix(),
ExpiresAt: now.Add(trustedDeviceTTL).Unix(),
}
if _, err := h.trustedDeviceRepo.Create(c.Request.Context(), dev); err != nil {
log.Warn().Err(err).Msg("trusted device: create failed")
return
}
c.SetCookie(trustedDeviceCookieName, token, int(trustedDeviceTTL.Seconds()), "/", "", false, true)
}
func clientIP(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
if ip := c.ClientIP(); ip != "" {
return ip
}
return c.Request.RemoteAddr
}
func trimToLen(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
// POST /api/logout
func (h *AuthHandler) Logout(c *gin.Context) {
traceID := middleware.GetTraceID(c)
// 1) Try bearer
authz := strings.TrimSpace(c.GetHeader("Authorization"))
if strings.HasPrefix(strings.ToLower(authz), "bearer ") {
token := strings.TrimSpace(authz[7:])
if token != "" {
h.sessionStore.Delete(token)
}
}
// 2) Try cookie sid
if sid, err := c.Cookie("sid"); err == nil && strings.TrimSpace(sid) != "" {
h.sessionStore.Delete(strings.TrimSpace(sid))
// 清 cookie
c.SetCookie("sid", "", -1, "/", "", false, true)
}
dto.Write(c, dto.Ok(traceID, dto.LogoutResp{}))
}