Files
goods/api/internal/auth/auth.go
T
oyaegeli98668 d90a539e6b
CI / Go (api) (pull_request) Failing after 18s
CI / Python (ingestion) (pull_request) Successful in 7s
CI / Migrations (postgres) (pull_request) Failing after 18s
feat(admin): 运营后台(登录/查看/审核编辑/补全)+ 写入API + 审计留痕
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 02:53:30 +00:00

127 lines
3.7 KiB
Go

// Package auth provides minimal single-account authentication for the admin
// console: a bcrypt-verified login and a stdlib HMAC-SHA256 signed token
// (JWT-compatible) plus a chi middleware that guards write routes.
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// Authenticator holds the single admin credential and token signing secret.
type Authenticator struct {
username string
passwordHash []byte
secret []byte
ttl time.Duration
}
// New builds an Authenticator. passwordHash must be a bcrypt hash.
func New(username string, passwordHash, secret []byte, ttl time.Duration) *Authenticator {
return &Authenticator{username: username, passwordHash: passwordHash, secret: secret, ttl: ttl}
}
// ErrInvalidCredentials is returned when login fails.
var ErrInvalidCredentials = errors.New("invalid credentials")
// Login verifies the username/password and returns a signed token on success.
func (a *Authenticator) Login(username, password string) (string, error) {
if username != a.username {
// Still run bcrypt to keep timing roughly constant.
_ = bcrypt.CompareHashAndPassword(a.passwordHash, []byte(password))
return "", ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword(a.passwordHash, []byte(password)); err != nil {
return "", ErrInvalidCredentials
}
return a.issue(username)
}
type claims struct {
Sub string `json:"sub"`
Exp int64 `json:"exp"`
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func (a *Authenticator) sign(signingInput string) string {
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(signingInput))
return b64(mac.Sum(nil))
}
func (a *Authenticator) issue(sub string) (string, error) {
header := b64([]byte(`{"alg":"HS256","typ":"JWT"}`))
payloadJSON, err := json.Marshal(claims{Sub: sub, Exp: time.Now().Add(a.ttl).Unix()})
if err != nil {
return "", err
}
payload := b64(payloadJSON)
signingInput := header + "." + payload
return signingInput + "." + a.sign(signingInput), nil
}
// Verify checks a token's signature and expiry, returning the subject.
func (a *Authenticator) Verify(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", errors.New("malformed token")
}
signingInput := parts[0] + "." + parts[1]
if !hmac.Equal([]byte(a.sign(signingInput)), []byte(parts[2])) {
return "", errors.New("bad signature")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", err
}
var c claims
if err := json.Unmarshal(payload, &c); err != nil {
return "", err
}
if time.Now().Unix() >= c.Exp {
return "", errors.New("token expired")
}
return c.Sub, nil
}
type ctxKey int
const userKey ctxKey = 0
// UserFrom returns the authenticated subject from the request context.
func UserFrom(ctx context.Context) string {
if v, ok := ctx.Value(userKey).(string); ok {
return v
}
return ""
}
// Middleware rejects requests without a valid Bearer token.
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
token := strings.TrimPrefix(h, "Bearer ")
if token == h || token == "" {
http.Error(w, `{"error":{"code":"unauthorized","message":"missing token"}}`, http.StatusUnauthorized)
return
}
sub, err := a.Verify(token)
if err != nil {
http.Error(w, `{"error":{"code":"unauthorized","message":"invalid token"}}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, sub)
next.ServeHTTP(w, r.WithContext(ctx))
})
}