auth: Registrierung, Login, Logout, per-user Sessions (Backend)
This commit is contained in:
parent
c7b4acea02
commit
0ef39cffd6
12 changed files with 703 additions and 40 deletions
|
|
@ -81,7 +81,7 @@ func run(cfgPath string, log *slog.Logger) error {
|
|||
p, _, err := pm.ForSession(ctx, sid)
|
||||
return p, err
|
||||
}}
|
||||
srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm,
|
||||
srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), TokenTTL: time.Duration(cfg.TokenTTLHours) * time.Hour, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm,
|
||||
Info: server.Info{Version: version, Provider: cfg.LLM.Provider, Model: cfg.LLM.Model, BaseURL: cfg.LLM.BaseURL},
|
||||
RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
# BYTECLAW_AUTH_TOKEN, BYTECLAW_LLM_API_KEY, BYTECLAW_LLM_BASE_URL, BYTECLAW_LLM_MODEL,
|
||||
# BYTECLAW_LISTEN, BYTECLAW_DATA_DIR, BYTECLAW_WORKSPACE, BYTECLAW_ALLOWED_ORIGINS (comma separated)
|
||||
listen: 127.0.0.1:8787 # bind to 0.0.0.0 only behind TLS (reverse proxy) or in a trusted LAN
|
||||
auth_token: "" # required, >= 16 chars. Generate: openssl rand -hex 32
|
||||
auth_token: "" # optional static admin token (>= 16 chars, sees all sessions). Generate: openssl rand -hex 32
|
||||
allow_registration: true # users sign up via POST /api/auth/register; set false after creating your account
|
||||
token_ttl_hours: 720 # login token lifetime
|
||||
allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it
|
||||
data_dir: ./data
|
||||
workspace: "" # default: <data_dir>/workspace; file tools cannot leave it
|
||||
|
|
|
|||
5
go.mod
5
go.mod
|
|
@ -1,6 +1,6 @@
|
|||
module github.com/crypto/byteclaw
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.13
|
||||
|
|
@ -14,8 +14,9 @@ require (
|
|||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.58.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -12,6 +12,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
|||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
|
|
@ -21,6 +23,8 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
|
|
|||
|
|
@ -76,7 +76,12 @@ type Tools struct {
|
|||
|
||||
type Config struct {
|
||||
Listen string `yaml:"listen" json:"listen"`
|
||||
// AuthToken is an optional static admin token (sees all sessions). Users log in via /api/auth.
|
||||
AuthToken string `yaml:"auth_token" json:"auth_token"`
|
||||
// AllowRegistration enables self-service sign-up (default true).
|
||||
AllowRegistration *bool `yaml:"allow_registration" json:"allow_registration"`
|
||||
// TokenTTLHours is the lifetime of login tokens (default 720 = 30 days).
|
||||
TokenTTLHours int `yaml:"token_ttl_hours" json:"token_ttl_hours"`
|
||||
// AllowedOrigins for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly.
|
||||
AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"`
|
||||
DataDir string `yaml:"data_dir" json:"data_dir"`
|
||||
|
|
@ -153,6 +158,10 @@ func applyEnv(c *Config, get func(string) string) {
|
|||
set("BYTECLAW_LLM_BASE_URL", &c.LLM.BaseURL)
|
||||
set("BYTECLAW_LLM_API_KEY", &c.LLM.APIKey)
|
||||
set("BYTECLAW_LLM_MODEL", &c.LLM.Model)
|
||||
if v := get("BYTECLAW_ALLOW_REGISTRATION"); v != "" {
|
||||
b := v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
|
||||
c.AllowRegistration = &b
|
||||
}
|
||||
if v := get("BYTECLAW_ALLOWED_ORIGINS"); v != "" {
|
||||
c.AllowedOrigins = nil
|
||||
for _, o := range strings.Split(v, ",") {
|
||||
|
|
@ -163,11 +172,14 @@ func applyEnv(c *Config, get func(string) string) {
|
|||
}
|
||||
}
|
||||
|
||||
// RegistrationAllowed reports allow_registration (default true).
|
||||
func (c Config) RegistrationAllowed() bool { return c.AllowRegistration == nil || *c.AllowRegistration }
|
||||
|
||||
// Validate enforces required fields and secure settings.
|
||||
func (c Config) Validate() error {
|
||||
var errs []error
|
||||
if len(c.AuthToken) < 16 {
|
||||
errs = append(errs, errors.New("auth_token must be set and at least 16 characters (BYTECLAW_AUTH_TOKEN)"))
|
||||
if c.AuthToken != "" && len(c.AuthToken) < 16 {
|
||||
errs = append(errs, errors.New("auth_token must be at least 16 characters when set (BYTECLAW_AUTH_TOKEN)"))
|
||||
}
|
||||
if c.LLM.BaseURL == "" || c.LLM.Model == "" {
|
||||
errs = append(errs, errors.New("llm.base_url and llm.model are required"))
|
||||
|
|
|
|||
|
|
@ -36,9 +36,21 @@ func TestLoadJSON(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiresToken(t *testing.T) {
|
||||
t.Setenv("BYTECLAW_AUTH_TOKEN", "")
|
||||
func TestValidateShortToken(t *testing.T) {
|
||||
t.Setenv("BYTECLAW_AUTH_TOKEN", "short")
|
||||
if _, err := Load(""); err == nil {
|
||||
t.Fatal("expected error without token")
|
||||
t.Fatal("expected error for short token")
|
||||
}
|
||||
t.Setenv("BYTECLAW_AUTH_TOKEN", "")
|
||||
c, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("token is optional now: %v", err)
|
||||
}
|
||||
if !c.RegistrationAllowed() {
|
||||
t.Fatal("registration must default to true")
|
||||
}
|
||||
t.Setenv("BYTECLAW_ALLOW_REGISTRATION", "false")
|
||||
if c, _ = Load(""); c.RegistrationAllowed() {
|
||||
t.Fatal("env must disable registration")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
284
internal/server/auth.go
Normal file
284
internal/server/auth.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/crypto/byteclaw/internal/store"
|
||||
)
|
||||
|
||||
// Principal is the authenticated caller. Admin (static auth_token) sees all sessions.
|
||||
type Principal struct {
|
||||
UserID int64
|
||||
Username string
|
||||
Admin bool
|
||||
token string
|
||||
}
|
||||
|
||||
// Scope is the user filter for store queries.
|
||||
func (p Principal) Scope() int64 {
|
||||
if p.Admin {
|
||||
return store.AllUsers
|
||||
}
|
||||
return p.UserID
|
||||
}
|
||||
|
||||
// OwnerID is the user_id new sessions get.
|
||||
func (p Principal) OwnerID() int64 {
|
||||
if p.Admin {
|
||||
return 0
|
||||
}
|
||||
return p.UserID
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
func principal(ctx context.Context) Principal {
|
||||
p, _ := ctx.Value(ctxKey{}).(Principal)
|
||||
return p
|
||||
}
|
||||
|
||||
const (
|
||||
defaultTokenTTL = 30 * 24 * time.Hour
|
||||
loginWindow = 15 * time.Minute
|
||||
loginMaxFails = 10
|
||||
)
|
||||
|
||||
// dummyHash keeps the timing of "unknown user" close to "wrong password".
|
||||
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("byteclaw-dummy-password"), bcrypt.DefaultCost)
|
||||
|
||||
// limiter counts failed attempts per IP in a fixed window.
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
max int
|
||||
win time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newLimiter(max int, win time.Duration) *limiter {
|
||||
return &limiter{hits: map[string][]time.Time{}, max: max, win: win, now: time.Now}
|
||||
}
|
||||
|
||||
func (l *limiter) prune(key string) []time.Time {
|
||||
cut := l.now().Add(-l.win)
|
||||
h := l.hits[key]
|
||||
i := 0
|
||||
for i < len(h) && h[i].Before(cut) {
|
||||
i++
|
||||
}
|
||||
h = h[i:]
|
||||
if len(h) == 0 {
|
||||
delete(l.hits, key)
|
||||
} else {
|
||||
l.hits[key] = h
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (l *limiter) Blocked(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.prune(key)) >= l.max
|
||||
}
|
||||
|
||||
func (l *limiter) Fail(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.hits[key] = append(l.prune(key), l.now())
|
||||
}
|
||||
|
||||
func (l *limiter) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
h, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (s *Server) initAuth() {
|
||||
s.authOnce.Do(func() {
|
||||
if s.limiter == nil {
|
||||
s.limiter = newLimiter(loginMaxFails, loginWindow)
|
||||
}
|
||||
if s.TokenTTL <= 0 {
|
||||
s.TokenTTL = defaultTokenTTL
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func bearer(r *http.Request) string {
|
||||
if t, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
|
||||
return strings.TrimSpace(t)
|
||||
}
|
||||
// Browsers cannot set headers on WebSocket upgrades: allow ?token= there only.
|
||||
if r.URL.Path == "/api/ws" {
|
||||
return r.URL.Query().Get("token")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// auth accepts the static admin token (constant-time) or a user token.
|
||||
func (s *Server) auth(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got := bearer(r)
|
||||
var p Principal
|
||||
switch {
|
||||
case got == "":
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
case s.Token != "" && subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) == 1:
|
||||
p = Principal{Admin: true, Username: "admin"}
|
||||
default:
|
||||
u, err := s.Store.UserByToken(r.Context(), got)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
p = Principal{UserID: u.ID, Username: u.Username}
|
||||
}
|
||||
p.token = got
|
||||
h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, p)))
|
||||
})
|
||||
}
|
||||
|
||||
type credentials struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func validUsername(u string) bool {
|
||||
if n := utf8.RuneCountInString(u); n < 3 || n > 32 {
|
||||
return false
|
||||
}
|
||||
for _, c := range u {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validPassword(p string) bool {
|
||||
// bcrypt only uses the first 72 bytes; reject longer input instead of silently truncating.
|
||||
return utf8.RuneCountInString(p) >= 8 && len(p) <= 72
|
||||
}
|
||||
|
||||
func (s *Server) issue(w http.ResponseWriter, r *http.Request, u store.User, code int) {
|
||||
tok, exp, err := s.Store.IssueToken(r.Context(), u.ID, s.TokenTTL)
|
||||
if err != nil {
|
||||
s.storeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, code, map[string]any{"token": tok, "expires_at": exp, "user": map[string]any{"id": u.ID, "username": u.Username}})
|
||||
}
|
||||
|
||||
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
s.initAuth()
|
||||
if !s.AllowRegistration {
|
||||
writeErr(w, http.StatusForbidden, "registration disabled")
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
if s.limiter.Blocked(ip) {
|
||||
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
|
||||
return
|
||||
}
|
||||
var c credentials
|
||||
if !decode(w, r, &c) {
|
||||
return
|
||||
}
|
||||
c.Username = strings.TrimSpace(c.Username)
|
||||
if !validUsername(c.Username) {
|
||||
writeErr(w, http.StatusBadRequest, "username must be 3-32 characters (letters, digits, . _ -)")
|
||||
return
|
||||
}
|
||||
if !validPassword(c.Password) {
|
||||
writeErr(w, http.StatusBadRequest, "password must be 8-72 characters")
|
||||
return
|
||||
}
|
||||
s.limiter.Fail(ip) // every registration counts against the IP budget
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
s.storeErr(w, err)
|
||||
return
|
||||
}
|
||||
u, err := s.Store.CreateUser(r.Context(), c.Username, string(hash))
|
||||
if errors.Is(err, store.ErrUserExists) {
|
||||
writeErr(w, http.StatusConflict, "username already taken")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.storeErr(w, err)
|
||||
return
|
||||
}
|
||||
s.issue(w, r, u, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
s.initAuth()
|
||||
ip := clientIP(r)
|
||||
if s.limiter.Blocked(ip) {
|
||||
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
|
||||
return
|
||||
}
|
||||
var c credentials
|
||||
if !decode(w, r, &c) {
|
||||
return
|
||||
}
|
||||
u, err := s.Store.UserByName(r.Context(), strings.TrimSpace(c.Username))
|
||||
hash := dummyHash
|
||||
if err == nil {
|
||||
hash = []byte(u.PasswordHash())
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
s.storeErr(w, err)
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(hash, []byte(c.Password)) != nil || err != nil {
|
||||
s.limiter.Fail(ip)
|
||||
writeErr(w, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(ip)
|
||||
s.issue(w, r, u, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
p := principal(r.Context())
|
||||
if !p.Admin {
|
||||
if err := s.Store.RevokeToken(r.Context(), p.token); err != nil {
|
||||
s.storeErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
p := principal(r.Context())
|
||||
writeJSON(w, 200, map[string]any{"id": p.UserID, "username": p.Username, "admin": p.Admin})
|
||||
}
|
||||
|
||||
// owns writes 404 and returns false unless the caller may access session id.
|
||||
func (s *Server) owns(w http.ResponseWriter, r *http.Request, id string) bool {
|
||||
if err := s.Store.SessionOwnedBy(r.Context(), principal(r.Context()).Scope(), id); err != nil {
|
||||
s.storeErr(w, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
200
internal/server/auth_test.go
Normal file
200
internal/server/auth_test.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/crypto/byteclaw/internal/agent"
|
||||
"github.com/crypto/byteclaw/internal/store"
|
||||
"github.com/crypto/byteclaw/internal/tools"
|
||||
)
|
||||
|
||||
func authSetup(t *testing.T, allowReg bool) (*httptest.Server, *store.Store, *Server) {
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "s.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
s := &Server{Store: st, Token: tok, AllowRegistration: allowReg, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second,
|
||||
Agent: &agent.Agent{LLM: echo{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 3}}
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, st, s
|
||||
}
|
||||
|
||||
type authResp struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
func creds(u, p string) string {
|
||||
b, _ := json.Marshal(map[string]string{"username": u, "password": p})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func registerUser(t *testing.T, base, u, p string) string {
|
||||
t.Helper()
|
||||
r := do(t, "POST", base+"/api/auth/register", creds(u, p), nil)
|
||||
if r.StatusCode != 201 {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
t.Fatalf("register %s: %d %s", u, r.StatusCode, b)
|
||||
}
|
||||
var a authResp
|
||||
json.NewDecoder(r.Body).Decode(&a)
|
||||
if a.Token == "" || a.User.Username != u {
|
||||
t.Fatalf("bad register response %+v", a)
|
||||
}
|
||||
return a.Token
|
||||
}
|
||||
|
||||
func bearerHdr(t string) map[string]string { return map[string]string{"Authorization": "Bearer " + t} }
|
||||
|
||||
func TestRegisterLoginMeLogout(t *testing.T) {
|
||||
ts, _, _ := authSetup(t, true)
|
||||
t1 := registerUser(t, ts.URL, "alice", "supersecret")
|
||||
if r := do(t, "POST", ts.URL+"/api/auth/register", creds("ALICE", "supersecret"), nil); r.StatusCode != 409 {
|
||||
t.Fatalf("duplicate (case-insensitive) must be 409, got %d", r.StatusCode)
|
||||
}
|
||||
r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1))
|
||||
var me map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&me)
|
||||
if r.StatusCode != 200 || me["username"] != "alice" {
|
||||
t.Fatalf("me: %d %v", r.StatusCode, me)
|
||||
}
|
||||
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrongpass"), nil); r.StatusCode != 401 {
|
||||
t.Fatalf("wrong password: %d", r.StatusCode)
|
||||
}
|
||||
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("nobody", "whatever1"), nil); r.StatusCode != 401 {
|
||||
t.Fatalf("unknown user: %d", r.StatusCode)
|
||||
}
|
||||
r = do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil)
|
||||
var a authResp
|
||||
json.NewDecoder(r.Body).Decode(&a)
|
||||
if r.StatusCode != 200 || a.Token == "" || a.Token == t1 {
|
||||
t.Fatalf("login: %d", r.StatusCode)
|
||||
}
|
||||
if r := do(t, "POST", ts.URL+"/api/auth/logout", "", bearerHdr(a.Token)); r.StatusCode != 204 {
|
||||
t.Fatalf("logout: %d", r.StatusCode)
|
||||
}
|
||||
if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(a.Token)); r.StatusCode != 401 {
|
||||
t.Fatalf("revoked token must be 401, got %d", r.StatusCode)
|
||||
}
|
||||
if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1)); r.StatusCode != 200 {
|
||||
t.Fatalf("other token must stay valid, got %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterValidation(t *testing.T) {
|
||||
ts, _, _ := authSetup(t, true)
|
||||
for _, c := range []struct{ u, p string }{{"ab", "longenough"}, {strings.Repeat("a", 33), "longenough"}, {"bad name", "longenough"}, {"bob", "short"}} {
|
||||
if r := do(t, "POST", ts.URL+"/api/auth/register", creds(c.u, c.p), nil); r.StatusCode != 400 {
|
||||
t.Fatalf("%q/%q: want 400 got %d", c.u, c.p, r.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationDisabled(t *testing.T) {
|
||||
ts, _, _ := authSetup(t, false)
|
||||
if r := do(t, "POST", ts.URL+"/api/auth/register", creds("alice", "supersecret"), nil); r.StatusCode != 403 {
|
||||
t.Fatalf("want 403 got %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequired(t *testing.T) {
|
||||
ts, _, _ := authSetup(t, true)
|
||||
for _, p := range []string{"/api/sessions", "/api/info", "/api/auth/me", "/api/providers", "/api/ws"} {
|
||||
if r := do(t, "GET", ts.URL+p, "", nil); r.StatusCode != 401 {
|
||||
t.Fatalf("%s without token: %d", p, r.StatusCode)
|
||||
}
|
||||
if r := do(t, "GET", ts.URL+p, "", bearerHdr("bc_invalid")); r.StatusCode != 401 {
|
||||
t.Fatalf("%s bad token: %d", p, r.StatusCode)
|
||||
}
|
||||
}
|
||||
if r := do(t, "GET", ts.URL+"/api/sessions?token="+tok, "", nil); r.StatusCode != 401 {
|
||||
t.Fatalf("query token must only work for ws, got %d", r.StatusCode)
|
||||
}
|
||||
// static admin token still works
|
||||
if r := do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tok)); r.StatusCode != 200 {
|
||||
t.Fatalf("static token: %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerUserIsolation(t *testing.T) {
|
||||
ts, st, _ := authSetup(t, true)
|
||||
legacy, _ := st.CreateSession(t.Context(), "legacy")
|
||||
ta := registerUser(t, ts.URL, "alice", "supersecret")
|
||||
tb := registerUser(t, ts.URL, "bob_2", "supersecret")
|
||||
r := do(t, "POST", ts.URL+"/api/sessions", `{"title":"bob's"}`, bearerHdr(tb))
|
||||
var bs store.Session
|
||||
json.NewDecoder(r.Body).Decode(&bs)
|
||||
|
||||
list := func(tk string) []store.Session {
|
||||
var l []store.Session
|
||||
json.NewDecoder(do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tk)).Body).Decode(&l)
|
||||
return l
|
||||
}
|
||||
if l := list(ta); len(l) != 1 || l[0].ID != legacy.ID {
|
||||
t.Fatalf("first user must adopt legacy session, got %+v", l)
|
||||
}
|
||||
if l := list(tb); len(l) != 1 || l[0].ID != bs.ID {
|
||||
t.Fatalf("bob sees %+v", l)
|
||||
}
|
||||
for _, m := range []struct{ method, path string }{{"GET", ""}, {"GET", "/messages"}, {"DELETE", ""}, {"POST", "/cancel"}, {"GET", "/summary"}} {
|
||||
if r := do(t, m.method, ts.URL+"/api/sessions/"+bs.ID+m.path, "", bearerHdr(ta)); r.StatusCode != 404 {
|
||||
t.Fatalf("alice %s %s on bob's session: %d", m.method, m.path, r.StatusCode)
|
||||
}
|
||||
}
|
||||
if r := do(t, "PATCH", ts.URL+"/api/sessions/"+bs.ID, `{"title":"x"}`, bearerHdr(ta)); r.StatusCode != 404 {
|
||||
t.Fatalf("rename foreign: %d", r.StatusCode)
|
||||
}
|
||||
if r := do(t, "POST", ts.URL+"/api/chat", `{"session_id":"`+bs.ID+`","text":"hi"}`, bearerHdr(ta)); r.StatusCode != 404 {
|
||||
t.Fatalf("chat into foreign session: %d", r.StatusCode)
|
||||
}
|
||||
if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, bearerHdr(ta)); r.StatusCode != 200 {
|
||||
t.Fatalf("own chat: %d", r.StatusCode)
|
||||
}
|
||||
if l := list(tb); len(l) != 1 {
|
||||
t.Fatalf("alice's new chat leaked to bob: %+v", l)
|
||||
}
|
||||
if l := list(tok); len(l) != 3 {
|
||||
t.Fatalf("admin must see all sessions, got %d", len(l))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimit(t *testing.T) {
|
||||
ts, _, s := authSetup(t, true)
|
||||
registerUser(t, ts.URL, "alice", "supersecret")
|
||||
s.limiter.Reset("127.0.0.1")
|
||||
for i := 0; i < loginMaxFails; i++ {
|
||||
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrong-pass"), nil); r.StatusCode != 401 {
|
||||
t.Fatalf("attempt %d: %d", i, r.StatusCode)
|
||||
}
|
||||
}
|
||||
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 429 {
|
||||
t.Fatalf("want 429 after %d failures, got %d", loginMaxFails, r.StatusCode)
|
||||
}
|
||||
// window expiry unblocks
|
||||
s.limiter.now = func() time.Time { return time.Now().Add(loginWindow + time.Second) }
|
||||
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 200 {
|
||||
t.Fatalf("after window: %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSQueryToken(t *testing.T) {
|
||||
ts, _, _ := authSetup(t, true)
|
||||
tk := registerUser(t, ts.URL, "alice", "supersecret")
|
||||
r := do(t, "GET", ts.URL+"/api/ws?token="+tk, "", map[string]string{"Connection": "Upgrade", "Upgrade": "websocket", "Sec-WebSocket-Version": "13", "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ=="})
|
||||
if r.StatusCode != http.StatusSwitchingProtocols {
|
||||
t.Fatalf("ws with query token: %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
|
@ -90,8 +90,7 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if _, err := s.Store.GetSession(r.Context(), id); err != nil {
|
||||
s.storeErr(w, err)
|
||||
if !s.owns(w, r, id) {
|
||||
return
|
||||
}
|
||||
var a providers.Active
|
||||
|
|
@ -110,13 +109,15 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) cancel(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.owns(w, r, r.PathValue("id")) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"cancelled": s.Agent.Cancel(r.PathValue("id"))})
|
||||
}
|
||||
|
||||
func (s *Server) summarize(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := s.Store.GetSession(r.Context(), id); err != nil {
|
||||
s.storeErr(w, err)
|
||||
if !s.owns(w, r, id) {
|
||||
return
|
||||
}
|
||||
sm, err := s.Agent.SummarizeNow(r.Context(), id)
|
||||
|
|
@ -133,6 +134,9 @@ func (s *Server) summarize(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.owns(w, r, r.PathValue("id")) {
|
||||
return
|
||||
}
|
||||
sm, err := s.Store.LatestSummary(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
|
@ -35,6 +34,13 @@ type Server struct {
|
|||
Info Info
|
||||
// Providers enables runtime provider switching (optional).
|
||||
Providers *providers.Manager
|
||||
// AllowRegistration enables POST /api/auth/register.
|
||||
AllowRegistration bool
|
||||
// TokenTTL is the lifetime of user tokens (default 30 days).
|
||||
TokenTTL time.Duration
|
||||
|
||||
authOnce sync.Once
|
||||
limiter *limiter
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
|
|
@ -47,7 +53,12 @@ type Info struct {
|
|||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) })
|
||||
s.initAuth()
|
||||
mux.HandleFunc("POST /api/auth/register", s.register)
|
||||
mux.HandleFunc("POST /api/auth/login", s.login)
|
||||
api := http.NewServeMux()
|
||||
api.HandleFunc("POST /api/auth/logout", s.logout)
|
||||
api.HandleFunc("GET /api/auth/me", s.me)
|
||||
api.HandleFunc("GET /api/info", s.info)
|
||||
api.HandleFunc("GET /api/providers", s.listProviders)
|
||||
api.HandleFunc("PUT /api/providers/active", s.setActiveProvider)
|
||||
|
|
@ -64,8 +75,10 @@ func (s *Server) Handler() http.Handler {
|
|||
api.HandleFunc("GET /api/sessions/{id}/messages", s.listMessages)
|
||||
api.HandleFunc("POST /api/chat", s.chat)
|
||||
api.HandleFunc("GET /api/ws", s.ws)
|
||||
mux.Handle("/api/", s.originCheck(s.auth(api)))
|
||||
return securityHeaders(mux)
|
||||
mux.Handle("/api/", s.auth(api))
|
||||
h := http.Handler(mux)
|
||||
h = s.originCheck(h)
|
||||
return securityHeaders(h)
|
||||
}
|
||||
|
||||
func securityHeaders(h http.Handler) http.Handler {
|
||||
|
|
@ -76,18 +89,6 @@ func securityHeaders(h http.Handler) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
// auth requires "Authorization: Bearer <token>" (constant-time compare).
|
||||
func (s *Server) auth(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) != 1 {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// OriginAllowed: requests without Origin (native apps, curl) pass; browser
|
||||
// requests must come from the same host or an explicitly configured origin.
|
||||
func (s *Server) OriginAllowed(r *http.Request) bool {
|
||||
|
|
@ -158,7 +159,7 @@ func (s *Server) storeErr(w http.ResponseWriter, err error) {
|
|||
}
|
||||
|
||||
func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.Store.ListSessions(r.Context(), 200)
|
||||
list, err := s.Store.ListSessionsFor(r.Context(), principal(r.Context()).Scope(), 200)
|
||||
if err != nil {
|
||||
s.storeErr(w, err)
|
||||
return
|
||||
|
|
@ -173,7 +174,7 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
|
|||
if r.ContentLength != 0 && !decode(w, r, &b) {
|
||||
return
|
||||
}
|
||||
ss, err := s.Store.CreateSession(r.Context(), b.Title)
|
||||
ss, err := s.Store.CreateSessionFor(r.Context(), principal(r.Context()).OwnerID(), b.Title)
|
||||
if err != nil {
|
||||
s.storeErr(w, err)
|
||||
return
|
||||
|
|
@ -182,6 +183,9 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) getSession(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.owns(w, r, r.PathValue("id")) {
|
||||
return
|
||||
}
|
||||
ss, err := s.Store.GetSession(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.storeErr(w, err)
|
||||
|
|
@ -197,6 +201,9 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) {
|
|||
if !decode(w, r, &b) {
|
||||
return
|
||||
}
|
||||
if !s.owns(w, r, r.PathValue("id")) {
|
||||
return
|
||||
}
|
||||
if err := s.Store.RenameSession(r.Context(), r.PathValue("id"), b.Title); err != nil {
|
||||
s.storeErr(w, err)
|
||||
return
|
||||
|
|
@ -205,6 +212,9 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.owns(w, r, r.PathValue("id")) {
|
||||
return
|
||||
}
|
||||
if err := s.Store.DeleteSession(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.storeErr(w, err)
|
||||
return
|
||||
|
|
@ -214,8 +224,7 @@ func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := s.Store.GetSession(r.Context(), id); err != nil {
|
||||
s.storeErr(w, err)
|
||||
if !s.owns(w, r, id) {
|
||||
return
|
||||
}
|
||||
msgs, err := s.Store.Messages(r.Context(), id, 0)
|
||||
|
|
@ -256,14 +265,13 @@ func (s *Server) startSession(ctx context.Context, req chatReq) (string, error)
|
|||
// ensureSession returns an existing session id or creates one titled after the message.
|
||||
func (s *Server) ensureSession(ctx context.Context, id, text string) (string, error) {
|
||||
if id != "" {
|
||||
_, err := s.Store.GetSession(ctx, id)
|
||||
return id, err
|
||||
return id, s.Store.SessionOwnedBy(ctx, principal(ctx).Scope(), id)
|
||||
}
|
||||
title := strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
|
||||
if utf8.RuneCountInString(title) > 48 {
|
||||
title = string([]rune(title)[:48]) + "…"
|
||||
}
|
||||
ss, err := s.Store.CreateSession(ctx, title)
|
||||
ss, err := s.Store.CreateSessionFor(ctx, principal(ctx).OwnerID(), title)
|
||||
return ss.ID, err
|
||||
}
|
||||
|
||||
|
|
@ -337,7 +345,7 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
|
|||
send(agent.Event{Type: "pong"})
|
||||
continue
|
||||
case "cancel":
|
||||
if req.SessionID == "" || !s.Agent.Cancel(req.SessionID) {
|
||||
if req.SessionID == "" || s.Store.SessionOwnedBy(ctx, principal(ctx).Scope(), req.SessionID) != nil || !s.Agent.Cancel(req.SessionID) {
|
||||
send(agent.Event{Type: "error", Text: "nothing to cancel", Session: req.SessionID})
|
||||
}
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -54,6 +54,15 @@ CREATE TABLE IF NOT EXISTS summaries(
|
|||
upto_message_id INTEGER NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id, id);
|
||||
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS users(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL, created_at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens(
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
|
||||
`
|
||||
|
||||
// migrations add columns to databases created by older versions.
|
||||
|
|
@ -61,6 +70,7 @@ var migrations = []struct{ table, column, ddl string }{
|
|||
{"sessions", "provider", "ALTER TABLE sessions ADD COLUMN provider TEXT NOT NULL DEFAULT ''"},
|
||||
{"sessions", "model", "ALTER TABLE sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''"},
|
||||
{"messages", "cancelled", "ALTER TABLE messages ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0"},
|
||||
{"sessions", "user_id", "ALTER TABLE sessions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0"},
|
||||
}
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
|
|
@ -103,10 +113,15 @@ func newID() string {
|
|||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, title string) (Session, error) {
|
||||
return s.CreateSessionFor(ctx, 0, title)
|
||||
}
|
||||
|
||||
// CreateSessionFor creates a session owned by userID (0 = legacy/admin).
|
||||
func (s *Store) CreateSessionFor(ctx context.Context, userID int64, title string) (Session, error) {
|
||||
now := time.Now().UTC()
|
||||
ss := Session{ID: newID(), Title: title, CreatedAt: now, UpdatedAt: now}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,created_at,updated_at) VALUES(?,?,?,?)`,
|
||||
ss.ID, ss.Title, now.UnixMilli(), now.UnixMilli())
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,user_id,created_at,updated_at) VALUES(?,?,?,?,?)`,
|
||||
ss.ID, ss.Title, userID, now.UnixMilli(), now.UnixMilli())
|
||||
return ss, err
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +137,16 @@ func (s *Store) GetSession(ctx context.Context, id string) (Session, error) {
|
|||
}
|
||||
|
||||
func (s *Store) ListSessions(ctx context.Context, limit int) ([]Session, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,title,provider,model,created_at,updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?`, limit)
|
||||
return s.ListSessionsFor(ctx, AllUsers, limit)
|
||||
}
|
||||
|
||||
// AllUsers disables per-user scoping (admin / static token).
|
||||
const AllUsers int64 = -1
|
||||
|
||||
// ListSessionsFor lists the sessions of userID (AllUsers = every session).
|
||||
func (s *Store) ListSessionsFor(ctx context.Context, userID int64, limit int) ([]Session, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,title,provider,model,created_at,updated_at FROM sessions
|
||||
WHERE (?<0 OR user_id=?) ORDER BY updated_at DESC LIMIT ?`, userID, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
112
internal/store/users.go
Normal file
112
internal/store/users.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrUserExists = errors.New("username already taken")
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
hash string
|
||||
}
|
||||
|
||||
func (u User) PasswordHash() string { return u.hash }
|
||||
|
||||
// CreateUser inserts a user. The first user ever created adopts all sessions
|
||||
// that existed before accounts were introduced (user_id 0).
|
||||
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) {
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO users(username,password_hash,created_at) VALUES(?,?,?)`, username, passwordHash, now.UnixMilli())
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return User{}, ErrUserExists
|
||||
}
|
||||
return User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
if n == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
}
|
||||
return User{ID: id, Username: username, CreatedAt: now, hash: passwordHash}, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) UserByName(ctx context.Context, username string) (User, error) {
|
||||
var u User
|
||||
var c int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id,username,password_hash,created_at FROM users WHERE username=?`, username).Scan(&u.ID, &u.Username, &u.hash, &c)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
u.CreatedAt = time.UnixMilli(c).UTC()
|
||||
return u, err
|
||||
}
|
||||
|
||||
func hashToken(t string) string {
|
||||
h := sha256.Sum256([]byte(t))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// IssueToken creates a random opaque token; only its SHA-256 is stored.
|
||||
func (s *Store) IssueToken(ctx context.Context, userID int64, ttl time.Duration) (string, time.Time, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
tok := "bc_" + base64.RawURLEncoding.EncodeToString(b)
|
||||
now := time.Now().UTC()
|
||||
exp := now.Add(ttl)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO auth_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`,
|
||||
hashToken(tok), userID, now.UnixMilli(), exp.UnixMilli())
|
||||
s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE expires_at<?`, now.UnixMilli())
|
||||
return tok, exp, err
|
||||
}
|
||||
|
||||
// UserByToken resolves a non-expired token (lookup by hash, so no timing leak on the secret).
|
||||
func (s *Store) UserByToken(ctx context.Context, tok string) (User, error) {
|
||||
var u User
|
||||
var c int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.created_at FROM auth_tokens t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.token_hash=? AND t.expires_at>?`, hashToken(tok), time.Now().UTC().UnixMilli()).Scan(&u.ID, &u.Username, &c)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
u.CreatedAt = time.UnixMilli(c).UTC()
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeToken(ctx context.Context, tok string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE token_hash=?`, hashToken(tok))
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionOwnedBy returns ErrNotFound unless the session exists and belongs to userID (AllUsers = any).
|
||||
func (s *Store) SessionOwnedBy(ctx context.Context, userID int64, id string) error {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions WHERE id=? AND (?<0 OR user_id=?)`, id, userID, userID).Scan(&n)
|
||||
if err == nil && n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue