236 lines
6.7 KiB
Go
236 lines
6.7 KiB
Go
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, "会话创建失败")
|
||
}
|
||
}
|