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) } // clientIP returns the peer address. If the peer is a trusted proxy, the // right-most X-Forwarded-For entry that is not itself a trusted proxy is used. func (s *Server) clientIP(r *http.Request) string { h, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { h = r.RemoteAddr } if !s.isTrustedProxy(h) { return h } var parts []string for _, v := range r.Header.Values("X-Forwarded-For") { for _, p := range strings.Split(v, ",") { if p = strings.TrimSpace(p); p != "" { parts = append(parts, p) } } } for i := len(parts) - 1; i >= 0; i-- { if net.ParseIP(parts[i]) == nil { break } if !s.isTrustedProxy(parts[i]) { return parts[i] } } return h } func (s *Server) isTrustedProxy(ip string) bool { p := net.ParseIP(ip) if p == nil { return false } if len(s.TrustedProxies) == 0 { return p.IsLoopback() } for _, c := range s.TrustedProxies { if c.Contains(p) { return true } } return false } // ParseCIDRs turns IPs or CIDRs into networks (bare IPs become /32 or /128). func ParseCIDRs(in []string) ([]*net.IPNet, error) { var out []*net.IPNet for _, v := range in { if !strings.Contains(v, "/") { if ip := net.ParseIP(v); ip != nil && ip.To4() != nil { v += "/32" } else { v += "/128" } } _, n, err := net.ParseCIDR(v) if err != nil { return nil, err } out = append(out, n) } return out, nil } 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"` InviteCode string `json:"invite_code,omitempty"` } 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() mode := s.regMode() if mode == "closed" { writeErr(w, http.StatusForbidden, "registration disabled") return } ip := s.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 } code := "" if mode == "invite" { if code = store.NormalizeInviteCode(c.InviteCode); code == "" { writeErr(w, http.StatusBadRequest, "invite code required") 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.CreateUserWithInvite(r.Context(), c.Username, string(hash), code) if errors.Is(err, store.ErrInviteInvalid) || errors.Is(err, store.ErrInviteUsed) || errors.Is(err, store.ErrInviteExpired) { writeErr(w, http.StatusForbidden, err.Error()) return } 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 := s.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 } // regMode is the effective registration mode. func (s *Server) regMode() string { switch s.RegistrationMode { case "open", "invite", "closed": return s.RegistrationMode } if s.AllowRegistration { return "open" } return "closed" }