byteclaw/internal/server/auth.go

366 lines
8.7 KiB
Go

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"
}