- 新增 internal/i18n 包:以原始中文字符串为 key 的翻译目录(zh 恒等, en 兜底,ja 缺译回退 en),t/tf 模板函数与 Accept-Language 解析 (按 q 值排序,兜底英语) - User 模型新增 language 字段(auto|en|zh|ja,默认 auto), AutoMigrate 自动加列;UserStore 新增 UpdateLanguage - 语言解析:登录用户按偏好(auto 时取浏览器 Accept-Language), 登录/封禁页按浏览器语言;所有 c.HTML 渲染注入 Lang 模板数据 - 全部页面模板(用户页 + 管理后台 12 页)接入翻译:导航、文件夹、 邮件列表/阅读/写信、设置、登录、封禁页、表单、表格、JS 确认框等; html lang 属性随语言切换;日期函数(shortDate/time12/time12m) 与 folderLabel 改为语言感知(上午/下午 vs AM/PM vs 午前/午後) - 设置页新增「界面语言」选择(自动/English/中文/日本語), /settings 表单支持单独提交语言偏好;管理后台用户表单可预设语言 - 封禁原因等入库中文文案在展示时经 t 翻译(未知 key 原样回退) - 测试:i18n 单测(解析/回退/译文)、render 测试补 Lang 数据、 时间格式化测试适配新签名;全量 go test 通过
362 lines
12 KiB
Go
362 lines
12 KiB
Go
package handlers
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"time"
|
||
|
||
"mail_go/config"
|
||
"mail_go/internal/auth"
|
||
"mail_go/internal/i18n"
|
||
"mail_go/internal/store"
|
||
|
||
"github.com/gin-contrib/sessions"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// AuthHandler handles authentication-related routes (login, logout, LDAP, OAuth2).
|
||
type AuthHandler struct {
|
||
stores *store.Stores
|
||
authCfg config.AuthConfig
|
||
banCfg config.BanConfig
|
||
}
|
||
|
||
// NewAuthHandler creates a new AuthHandler with the given stores, auth config, and ban config.
|
||
func NewAuthHandler(stores *store.Stores, authCfg config.AuthConfig, banCfg config.BanConfig) *AuthHandler {
|
||
return &AuthHandler{stores: stores, authCfg: authCfg, banCfg: banCfg}
|
||
}
|
||
|
||
// ShowLogin renders the login page.
|
||
func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
||
// If already logged in, redirect to inbox
|
||
session := sessions.Default(c)
|
||
if session.Get("userID") != nil {
|
||
c.Redirect(302, "/inbox")
|
||
return
|
||
}
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": "",
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
}
|
||
|
||
// DoLogin processes the login form submission.
|
||
// It authenticates the user with email and password, sets session data
|
||
// on success, or re-renders the login page with an error on failure.
|
||
func (h *AuthHandler) DoLogin(c *gin.Context) {
|
||
ip := c.ClientIP()
|
||
|
||
// Check if IP is banned
|
||
banned, entry := h.stores.Bans.IsBanned(ip)
|
||
if banned {
|
||
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
|
||
return
|
||
}
|
||
|
||
email := c.PostForm("email")
|
||
password := c.PostForm("password")
|
||
|
||
if email == "" || password == "" {
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "请输入邮箱和密码"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
user, err := h.stores.Users.Authenticate(email, password)
|
||
if err != nil {
|
||
// 区分失败性质:用户名存在(真实用户输错,保留宽限)vs
|
||
// 用户名不存在(枚举型爆破,跳过宽限首次触发即封)
|
||
knownUser := h.stores.Users.LoginExists(email)
|
||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多", knownUser)
|
||
if banned {
|
||
entry, _ := h.stores.Bans.GetByIP(ip)
|
||
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
|
||
return
|
||
}
|
||
|
||
remaining := h.banCfg.MaxFailAttempts - failCount
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.TF(langOf(c), "用户名或密码错误,还剩 %d 次尝试机会", remaining),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
// Login successful: reset fail count
|
||
h.stores.Bans.ResetFail(ip)
|
||
|
||
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
|
||
// 供中间件做绝对过期与滑动续期)
|
||
session := sessions.Default(c)
|
||
session.Clear()
|
||
session.Set("userID", user.ID)
|
||
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||
session.Set("isAdmin", user.IsAdmin)
|
||
session.Set("loginAt", time.Now().Unix())
|
||
if err := session.Save(); err != nil {
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
c.Redirect(302, "/inbox")
|
||
}
|
||
|
||
// LDAPLogin handles LDAP authentication form submission.
|
||
func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
||
ip := c.ClientIP()
|
||
|
||
// Check if IP is banned
|
||
banned, entry := h.stores.Bans.IsBanned(ip)
|
||
if banned {
|
||
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
|
||
return
|
||
}
|
||
|
||
username := c.PostForm("username")
|
||
password := c.PostForm("password")
|
||
|
||
if username == "" || password == "" {
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "请输入LDAP用户名和密码"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
provider := auth.NewLDAPProvider(h.authCfg)
|
||
email, err := provider.Authenticate(map[string]string{
|
||
"username": username,
|
||
"password": password,
|
||
})
|
||
if err != nil {
|
||
log.Printf("LDAP 认证失败: %v", err)
|
||
|
||
// LDAP 侧用户存在性无法判定,保守按已知用户处理(保留宽限防误封)
|
||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多", true)
|
||
if banned {
|
||
entry, _ := h.stores.Bans.GetByIP(ip)
|
||
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
|
||
return
|
||
}
|
||
|
||
remaining := h.banCfg.MaxFailAttempts - failCount
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.TF(langOf(c), "LDAP 认证失败,还剩 %d 次尝试机会", remaining),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
// Look up or auto-create user by email
|
||
user, err := h.stores.Users.GetByEmail(email)
|
||
if err != nil {
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "LDAP 账号未接入本系统,请联系管理员"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
if !user.IsActive {
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "用户已被禁用"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
// Login successful: reset fail count
|
||
h.stores.Bans.ResetFail(ip)
|
||
|
||
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
|
||
// 供中间件做绝对过期与滑动续期)
|
||
session := sessions.Default(c)
|
||
session.Clear()
|
||
session.Set("userID", user.ID)
|
||
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||
session.Set("isAdmin", user.IsAdmin)
|
||
session.Set("loginAt", time.Now().Unix())
|
||
if err := session.Save(); err != nil {
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
c.Redirect(302, "/inbox")
|
||
}
|
||
|
||
// OAuth2 state cookie 配置。state 用于防止登录 CSRF / 授权码注入:
|
||
// 发起授权时下发随机值,回调时必须原样带回。
|
||
//
|
||
// 注意 state 不能放进主会话 cookie:主会话是 SameSite=Strict,
|
||
// OAuth2 回调是从 IdP 发起的跨站顶级导航,浏览器不会携带 Strict
|
||
// cookie,因此使用独立的短期 SameSite=Lax cookie。
|
||
const (
|
||
oauth2StateCookie = "mail_go_oauth2_state"
|
||
oauth2StateMaxAge = 600 // 秒,10 分钟内完成授权流程
|
||
oauth2StateRandLen = 16 // 随机字节数(hex 编码后 32 字符)
|
||
)
|
||
|
||
// randomOAuth2State generates a hex-encoded cryptographically random state.
|
||
func randomOAuth2State() (string, error) {
|
||
buf := make([]byte, oauth2StateRandLen)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", fmt.Errorf("生成 OAuth2 state 失败: %w", err)
|
||
}
|
||
return hex.EncodeToString(buf), nil
|
||
}
|
||
|
||
// oauth2LoginVars 是登录模板所需的公共变量。
|
||
func (h *AuthHandler) oauth2LoginVars() gin.H {
|
||
return gin.H{
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}
|
||
}
|
||
|
||
// OAuth2Start redirects to the OAuth2 provider's authorization page.
|
||
func (h *AuthHandler) OAuth2Start(c *gin.Context) {
|
||
if !h.authCfg.OAuth2Enabled {
|
||
c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用"))
|
||
return
|
||
}
|
||
|
||
provider := auth.NewOAuth2Provider(h.authCfg)
|
||
state, err := randomOAuth2State()
|
||
if err != nil {
|
||
log.Printf("生成 OAuth2 state 失败: %v", err)
|
||
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "OAuth2 登录暂不可用,请稍后重试"))
|
||
return
|
||
}
|
||
c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true)
|
||
c.Redirect(http.StatusFound, provider.GetAuthURL(state))
|
||
}
|
||
|
||
// OAuth2Callback handles the OAuth2 provider's callback after user authorization.
|
||
func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
||
if !h.authCfg.OAuth2Enabled {
|
||
c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用"))
|
||
return
|
||
}
|
||
|
||
// 校验 state:必须与发起授权时下发的随机值一致(常量时间比较)。
|
||
// 缺失或不匹配视为登录 CSRF / 授权码注入,直接拒绝。
|
||
cookieState, cookieErr := c.Cookie(oauth2StateCookie)
|
||
reqState := c.Query("state")
|
||
if cookieErr != nil || reqState == "" ||
|
||
subtle.ConstantTimeCompare([]byte(cookieState), []byte(reqState)) != 1 {
|
||
c.HTML(http.StatusForbidden, "login", withLang(c, func() gin.H {
|
||
v := h.oauth2LoginVars()
|
||
v["error"] = i18n.T(langOf(c), "OAuth2 state 校验失败,请重新发起登录")
|
||
return v
|
||
}()))
|
||
return
|
||
}
|
||
// state 一次性使用:无论后续成败都立即失效
|
||
c.SetCookie(oauth2StateCookie, "", -1, "/auth/oauth2", "", true, true)
|
||
|
||
code := c.Query("code")
|
||
if code == "" {
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "OAuth2 授权码缺失"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
provider := auth.NewOAuth2Provider(h.authCfg)
|
||
email, err := provider.HandleCallback(code)
|
||
if err != nil {
|
||
log.Printf("OAuth2 回调失败: %v", err)
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "OAuth2 认证失败,请重试或联系管理员"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
// Look up user by email
|
||
user, err := h.stores.Users.GetByEmail(email)
|
||
if err != nil {
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "OAuth2 账号未接入本系统,请联系管理员"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
if !user.IsActive {
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "用户已被禁用"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
|
||
// 供中间件做绝对过期与滑动续期)
|
||
session := sessions.Default(c)
|
||
session.Clear()
|
||
session.Set("userID", user.ID)
|
||
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||
session.Set("isAdmin", user.IsAdmin)
|
||
session.Set("loginAt", time.Now().Unix())
|
||
if err := session.Save(); err != nil {
|
||
c.HTML(200, "login", withLang(c, gin.H{
|
||
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
|
||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||
}))
|
||
return
|
||
}
|
||
|
||
c.Redirect(302, "/inbox")
|
||
}
|
||
|
||
// DoLogout clears the session and redirects to the login page.
|
||
func (h *AuthHandler) DoLogout(c *gin.Context) {
|
||
session := sessions.Default(c)
|
||
session.Clear()
|
||
session.Save()
|
||
c.Redirect(302, "/login")
|
||
}
|