Files

236 lines
6.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
)
type loginLimit struct {
Count int
Until time.Time
}
type credentials struct {
Password string `json:"password"`
Token string `json:"token"`
CurrentPassword string `json:"currentPassword"`
}
func decode(w http.ResponseWriter, r *http.Request, target any) error {
r.Body = http.MaxBytesReader(w, r.Body, 16<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return errors.New("请求内容无效或过大")
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return errors.New("请求必须是单个 JSON 对象")
}
return nil
}
func jsonResponse(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, message string) {
jsonResponse(w, status, map[string]string{"error": message})
}
func hashToken(token string) string {
b := sha256.Sum256([]byte(token))
return hex.EncodeToString(b[:])
}
func validPassword(password string) bool { return len(password) >= 12 && len(password) <= 72 }
func (a *App) session(r *http.Request) (string, string, error) {
cookie, err := r.Cookie("cw_session")
if err != nil {
return "", "", err
}
hash := hashToken(cookie.Value)
var csrf string
var expires int64
err = a.store.db.QueryRow("SELECT csrf,expires FROM sessions WHERE token_hash=?", hash).Scan(&csrf, &expires)
if err != nil || expires <= time.Now().Unix() {
return "", "", errors.New("会话已过期")
}
return hash, csrf, nil
}
func (a *App) newSession(w http.ResponseWriter) error {
token, csrf := randomToken(), randomToken()
expires := time.Now().Add(24 * time.Hour)
if _, err := a.store.db.Exec("INSERT INTO sessions(token_hash,csrf,expires) VALUES(?,?,?)", hashToken(token), csrf, expires.Unix()); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{Name: "cw_session", Value: token, Path: "/", HttpOnly: true, Secure: a.secureCookie, SameSite: http.SameSiteStrictMode, MaxAge: 86400, Expires: expires})
jsonResponse(w, 200, map[string]any{"authenticated": true, "csrf": csrf})
return nil
}
func (a *App) protect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, csrf, err := a.session(r)
if err != nil {
writeError(w, 401, "请先登录")
return
}
if r.Method != "GET" && r.Method != "HEAD" {
if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(csrf)) != 1 {
writeError(w, 403, "会话校验失败,请重新登录")
return
}
}
next.ServeHTTP(w, r)
})
}
func (a *App) authStatus(w http.ResponseWriter, r *http.Request) {
password, err := a.store.Get("password")
if err != nil {
writeError(w, 500, "数据库读取失败")
return
}
_, csrf, err := a.session(r)
jsonResponse(w, 200, map[string]any{"initialized": password != "", "authenticated": err == nil, "csrf": csrf})
}
func (a *App) setup(w http.ResponseWriter, r *http.Request) {
a.authMu.Lock()
defer a.authMu.Unlock()
password, err := a.store.Get("password")
if err != nil {
writeError(w, 500, "数据库读取失败")
return
}
if password != "" {
writeError(w, 409, "管理员已经设置")
return
}
var input credentials
if err := decode(w, r, &input); err != nil {
writeError(w, 400, err.Error())
return
}
if subtle.ConstantTimeCompare([]byte(input.Token), []byte(a.setupToken)) != 1 {
writeError(w, 403, "首次设置令牌不正确,请查看服务启动日志")
return
}
if !validPassword(input.Password) {
writeError(w, 400, "密码需为 12–72 字节")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, 500, "密码处理失败")
return
}
if err := a.store.Set("password", string(hash)); err != nil {
writeError(w, 500, "保存失败")
return
}
_ = a.store.Set("setup_token", "")
a.setupToken = ""
if err := a.newSession(w); err != nil {
writeError(w, 500, "会话创建失败")
}
}
func (a *App) login(w http.ResponseWriter, r *http.Request) {
a.authMu.Lock()
defer a.authMu.Unlock()
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
limit := a.failures[ip]
if limit.Count >= 5 && time.Now().Before(limit.Until) {
writeError(w, 429, "登录尝试过多,请 5 分钟后重试")
return
}
if time.Now().After(limit.Until) {
limit = loginLimit{}
}
var input credentials
if err := decode(w, r, &input); err != nil {
writeError(w, 400, err.Error())
return
}
password, err := a.store.Get("password")
if err != nil {
writeError(w, 500, "读取失败")
return
}
if password == "" || bcrypt.CompareHashAndPassword([]byte(password), []byte(input.Password)) != nil {
if len(a.failures) > 1024 {
a.failures = map[string]loginLimit{}
}
limit.Count++
limit.Until = time.Now().Add(5 * time.Minute)
a.failures[ip] = limit
writeError(w, 401, "密码不正确")
return
}
delete(a.failures, ip)
if err := a.newSession(w); err != nil {
writeError(w, 500, "会话创建失败")
}
}
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
hash, _, _ := a.session(r)
_, err := a.store.db.Exec("DELETE FROM sessions WHERE token_hash=?", hash)
if err != nil {
writeError(w, 500, "退出失败")
return
}
http.SetCookie(w, &http.Cookie{Name: "cw_session", Path: "/", HttpOnly: true, Secure: a.secureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1})
jsonResponse(w, 200, map[string]bool{"ok": true})
}
func (a *App) password(w http.ResponseWriter, r *http.Request) {
a.authMu.Lock()
defer a.authMu.Unlock()
var input credentials
if err := decode(w, r, &input); err != nil {
writeError(w, 400, err.Error())
return
}
old, err := a.store.Get("password")
if err != nil || bcrypt.CompareHashAndPassword([]byte(old), []byte(input.CurrentPassword)) != nil {
writeError(w, 400, "当前密码不正确")
return
}
if !validPassword(input.Password) {
writeError(w, 400, "新密码需为 12–72 字节")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, 500, "密码处理失败")
return
}
tx, err := a.store.db.Begin()
if err != nil {
writeError(w, 500, "保存失败")
return
}
defer tx.Rollback()
if _, err = tx.Exec("UPDATE kv SET value=? WHERE key='password'", string(hash)); err == nil {
_, err = tx.Exec("DELETE FROM sessions")
}
if err != nil || tx.Commit() != nil {
writeError(w, 500, "保存失败")
return
}
if err := a.newSession(w); err != nil {
writeError(w, 500, "会话创建失败")
}
}