clearlove2.1
1// 用户认证:登录、注册(可选邮箱验证码)、登出。
2package handlers
3
4import (
5 "crypto/rand"
6 "fmt"
7 "net/http"
8 "net/mail"
9 "regexp"
10 "strings"
11 "sync"
12 "time"
13
14 "clearlove/internal/database"
15 "clearlove/internal/mailer"
16 "clearlove/internal/models"
17 "clearlove/internal/plugin"
18 "clearlove/internal/util"
19)
20
21// Go 正则不支持 \u 转义,使用 \x{...} 表示中文区间
22var usernameRe = regexp.MustCompile(`^[\w\x{4e00}-\x{9fa5}]{3,20}$`)
23
24// ---------- 通用失败计数(防爆破/轰炸,进程内内存态) ----------
25
26type tryEntry struct {
27 Count int
28 Until time.Time
29}
30
31var (
32 codeTryMu sync.Mutex
33 codeTries = map[string]tryEntry{}
34 codeTriesSeen = time.Now()
35)
36
37// codeTryBlocked 是否被锁定(连续失败 5 次锁 10 分钟)
38func codeTryBlocked(key string) bool {
39 codeTryMu.Lock()
40 defer codeTryMu.Unlock()
41 t, ok := codeTries[key]
42 if !ok {
43 return false
44 }
45 if time.Now().After(t.Until) {
46 delete(codeTries, key)
47 return false
48 }
49 return t.Count >= 5
50}
51
52func codeTryFail(key string) {
53 codeTryMu.Lock()
54 defer codeTryMu.Unlock()
55 // 定期整体回收过期条目,防止伪造 key 导致 map 无界增长
56 if time.Since(codeTriesSeen) > time.Hour {
57 codeTriesSeen = time.Now()
58 now := time.Now()
59 for k, v := range codeTries {
60 if now.After(v.Until) {
61 delete(codeTries, k)
62 }
63 }
64 }
65 t := codeTries[key]
66 t.Count++
67 t.Until = time.Now().Add(10 * time.Minute)
68 codeTries[key] = t
69}
70
71func codeTryReset(key string) {
72 codeTryMu.Lock()
73 defer codeTryMu.Unlock()
74 delete(codeTries, key)
75}
76
77// codeTryCount 当前失败次数(未锁定也返回计数,用于发码限流判断)
78func codeTryCount(key string) int {
79 codeTryMu.Lock()
80 defer codeTryMu.Unlock()
81 t, ok := codeTries[key]
82 if !ok || time.Now().After(t.Until) {
83 return 0
84 }
85 return t.Count
86}
87
88// LoginPage 登录页
89func LoginPage(w http.ResponseWriter, r *http.Request) {
90 Render(w, r, "layout.html", "pg_login", map[string]any{
91 "err": r.URL.Query().Get("err"),
92 "next": r.URL.Query().Get("next"),
93 })
94}
95
96// LoginSubmit 登录校验,成功写入签名 Cookie(30天)
97func LoginSubmit(w http.ResponseWriter, r *http.Request) {
98 username := strings.TrimSpace(r.FormValue("username"))
99 password := r.FormValue("password")
100 var id int64
101 var hash string
102 var status int
103 err := database.DB.QueryRow("SELECT id,password,status FROM users WHERE username=?", username).
104 Scan(&id, &hash, &status)
105 if err != nil || !util.CheckPassword(hash, password) {
106 Render(w, r, "layout.html", "pg_login", map[string]any{"err": "用户名或密码错误"})
107 return
108 }
109 if status != 1 {
110 Render(w, r, "layout.html", "pg_login", map[string]any{"err": "账号已被禁用"})
111 return
112 }
113 // 应用型插件可拦截登录(filter.auth.login,如封禁名单、黑名单校验)
114 if !plugin.ApplyFilterBool("filter.auth.login", true, map[string]any{"user_id": id, "username": username}) {
115 Render(w, r, "layout.html", "pg_login", map[string]any{"err": "登录被站点插件拦截"})
116 return
117 }
118 http.SetCookie(w, &http.Cookie{
119 Name: "clv_user", Value: util.SignSession(id, 30*24*time.Hour), Path: "/",
120 MaxAge: 30 * 86400, HttpOnly: true, SameSite: http.SameSiteLaxMode,
121 })
122 plugin.Emit("user_login", map[string]any{"user_id": id, "username": username})
123 next := r.FormValue("next")
124 if next == "" || !strings.HasPrefix(next, "/") {
125 next = "/"
126 }
127 http.Redirect(w, r, next, http.StatusSeeOther)
128}
129
130// Logout 登出
131func Logout(w http.ResponseWriter, r *http.Request) {
132 http.SetCookie(w, &http.Cookie{Name: "clv_user", Value: "", Path: "/", MaxAge: -1})
133 http.Redirect(w, r, "/", http.StatusSeeOther)
134}
135
136// RegisterPage 注册页
137func RegisterPage(w http.ResponseWriter, r *http.Request) {
138 Render(w, r, "layout.html", "pg_register", map[string]any{
139 "err": r.URL.Query().Get("err"),
140 "allow": models.GetSetting("allow_register") == "1",
141 "needCode": models.GetSetting("smtp_host") != "",
142 })
143}
144
145// RegisterSubmit 注册。配置了 SMTP 时必须校验邮箱验证码
146func RegisterSubmit(w http.ResponseWriter, r *http.Request) {
147 if models.GetSetting("allow_register") != "1" {
148 http.Error(w, "站点已关闭注册", http.StatusForbidden)
149 return
150 }
151 username := strings.TrimSpace(r.FormValue("username"))
152 email := strings.TrimSpace(r.FormValue("email"))
153 password := r.FormValue("password")
154 code := strings.TrimSpace(r.FormValue("code"))
155 q := func(msg string) {
156 http.Redirect(w, r, "/register?err="+urlQueryEscape(msg), http.StatusSeeOther)
157 }
158
159 if !usernameRe.MatchString(username) {
160 q("用户名需为 3-20 位字母、数字、下划线或中文")
161 return
162 }
163 // 管理员专享昵称不允许被注册,避免冒充官方身份
164 if models.IsAdminNickname(username) {
165 q("该名称为管理员专享,请更换用户名")
166 return
167 }
168 if _, err := mail.ParseAddress(email); err != nil {
169 q("邮箱格式不正确")
170 return
171 }
172 if len(password) < 6 {
173 q("密码至少 6 位")
174 return
175 }
176 if models.QueryInt("SELECT COUNT(1) FROM users WHERE username=?", username) > 0 {
177 q("用户名已被占用")
178 return
179 }
180 // SMTP 已配置则强制校验验证码
181 if models.GetSetting("smtp_host") != "" {
182 key := "vcode:" + email
183 var vid int64
184 var dbCode string
185 var exp string
186 err := database.DB.QueryRow(
187 "SELECT id,code,expires FROM verifications WHERE email=? ORDER BY id DESC LIMIT 1", email).Scan(&vid, &dbCode, &exp)
188 if err != nil || !codeValid(exp) {
189 q("邮箱验证码错误或已过期")
190 return
191 }
192 if dbCode != code {
193 // 防爆破:同一邮箱连续错误 5 次即作废当前验证码,必须重新获取
194 if codeTryBlocked(key) {
195 _, _ = database.DB.Exec("DELETE FROM verifications WHERE id=?", vid)
196 codeTryReset(key)
197 q("验证码错误次数过多,请重新获取验证码")
198 return
199 }
200 codeTryFail(key)
201 q("邮箱验证码错误或已过期")
202 return
203 }
204 codeTryReset(key)
205 }
206 res, err := database.DB.Exec(
207 "INSERT INTO users(username,email,password,status,created_at) VALUES(?,?,?,1,?)",
208 username, email, util.HashPassword(password), models.Now())
209 if err != nil {
210 q("注册失败,请稍后重试")
211 return
212 }
213 uid, _ := res.LastInsertId()
214 plugin.Emit("user_registered", map[string]any{"user_id": uid, "username": username})
215 _, _ = database.DB.Exec("DELETE FROM verifications WHERE email=?", email)
216 http.SetCookie(w, &http.Cookie{
217 Name: "clv_user", Value: util.SignSession(uid, 30*24*time.Hour), Path: "/",
218 MaxAge: 30 * 86400, HttpOnly: true, SameSite: http.SameSiteLaxMode,
219 })
220 http.Redirect(w, r, "/", http.StatusSeeOther)
221}
222
223// codeValid 验证码 10 分钟有效期
224func codeValid(expRFC string) bool {
225 t, err := time.Parse(time.RFC3339, expRFC)
226 return err == nil && time.Now().UTC().Before(t)
227}
228
229// RegisterCode 发送注册验证码(POST /api/register-code)
230func RegisterCode(w http.ResponseWriter, r *http.Request) {
231 host := models.GetSetting("smtp_host")
232 if host == "" {
233 fail(w, 400, "站点未配置邮箱服务,无需验证码")
234 return
235 }
236 email := strings.TrimSpace(r.FormValue("email"))
237 if _, err := mail.ParseAddress(email); err != nil {
238 fail(w, 400, "邮箱格式不正确")
239 return
240 }
241 // 发码限流:同一邮箱 10 分钟内最多 3 次,同一 IP 10 分钟内最多 10 次,防 SMTP 轰炸
242 ip := util.ClientIP(r)
243 if codeTryCount("send:"+email) >= 3 {
244 fail(w, 429, "发送过于频繁,请稍后再试")
245 return
246 }
247 if codeTryCount("sendip:"+ip) >= 10 {
248 fail(w, 429, "发送过于频繁,请稍后再试")
249 return
250 }
251 if models.QueryInt("SELECT COUNT(1) FROM users WHERE email=?", email) > 0 {
252 fail(w, 400, "该邮箱已注册")
253 return
254 }
255 code := fmt.Sprintf("%06d", randomInt(1000000))
256 // 同邮箱旧验证码作废,避免多条并存增加爆破面
257 _, _ = database.DB.Exec("DELETE FROM verifications WHERE email=?", email)
258 _, err := database.DB.Exec(
259 "INSERT INTO verifications(email,code,expires) VALUES(?,?,?)",
260 email, code, time.Now().UTC().Add(10*time.Minute).Format(time.RFC3339))
261 if err != nil {
262 fail(w, 500, "验证码生成失败")
263 return
264 }
265 codeTryFail("send:" + email)
266 codeTryFail("sendip:" + ip)
267 err = mailer.SendCode(host, models.GetSetting("smtp_port"),
268 models.GetSetting("smtp_user"), models.GetSetting("smtp_pass"),
269 models.GetSetting("smtp_from"), email, code)
270 if err != nil {
271 util.Log("error", "验证码邮件发送失败: %v", err)
272 fail(w, 500, "验证码发送失败,请稍后重试")
273 return
274 }
275 okJSON(w, map[string]any{"msg": "验证码已发送,请查收邮箱"})
276}
277
278// randomInt [0,n) 随机整数
279func randomInt(n int) int {
280 b := make([]byte, 4)
281 _, _ = rand.Read(b)
282 v := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
283 if v < 0 {
284 v = -v
285 }
286 return v % n
287}
288
289// urlQueryEscape 简单查询串转义
290func urlQueryEscape(s string) string {
291 return strings.ReplaceAll(strings.ReplaceAll(s, "&", "%26"), "+", "%2B")
292}