package api import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "net" "net/http" "net/url" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" "github.com/cdnutter/playlink/internal/store" ) const ( cookieName = "playlink_session" sessionTTL = 30 * 24 * time.Hour csrfHeader = "X-Playlink-Request" ) type ctxKey int const userKey ctxKey = 1 func hashToken(t string) string { h := sha256.Sum256([]byte(t)) return hex.EncodeToString(h[:]) } func newToken() (string, error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b[:]), nil } // limiter is a small per-IP fixed-window limiter for login attempts. type limiter struct { mu sync.Mutex hits map[string][]time.Time max int window time.Duration } func newLimiter(max int, window time.Duration) *limiter { return &limiter{hits: map[string][]time.Time{}, max: max, window: window} } func (l *limiter) allow(key string) bool { l.mu.Lock() defer l.mu.Unlock() now := time.Now() kept := l.hits[key][:0] for _, t := range l.hits[key] { if now.Sub(t) < l.window { kept = append(kept, t) } } if len(kept) >= l.max { l.hits[key] = kept return false } l.hits[key] = append(kept, now) return true } func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func (s *Server) startSession(w http.ResponseWriter, r *http.Request, uid int64) error { tok, err := newToken() if err != nil { return err } exp := time.Now().Add(sessionTTL) if err := s.st.CreateSession(r.Context(), hashToken(tok), uid, exp.Unix()); err != nil { return err } http.SetCookie(w, &http.Cookie{Name: cookieName, Value: tok, Path: "/", Expires: exp, HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode}) return nil } func (s *Server) currentUser(r *http.Request) (store.User, string, error) { c, err := r.Cookie(cookieName) if err != nil || c.Value == "" { return store.User{}, "", store.ErrNotFound } th := hashToken(c.Value) uid, err := s.st.SessionUser(r.Context(), th) if err != nil { return store.User{}, "", err } u, err := s.st.UserByID(r.Context(), uid) return u, th, err } // requireAuth rejects unauthenticated requests and, for state-changing // methods, requests that are not same-origin API calls (CSRF defence in // addition to the SameSite=Strict cookie). func (s *Server) requireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, _, err := s.currentUser(r) if err != nil { writeErr(w, http.StatusUnauthorized, errors.New("not signed in")) return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, u))) }) } func (s *Server) csrf(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: default: if r.Header.Get(csrfHeader) != "1" { writeErr(w, http.StatusForbidden, errors.New("missing request header")) return } if o := r.Header.Get("Origin"); o != "" { u, err := url.Parse(o) if err != nil || !strings.EqualFold(u.Host, r.Host) { writeErr(w, http.StatusForbidden, errors.New("cross-origin request refused")) return } } } next.ServeHTTP(w, r) }) } type credentials struct { Username string `json:"username"` Password string `json:"password"` } func validPassword(p string) error { if len(p) < 10 { return errors.New("password must be at least 10 characters") } if len(p) > 72 { return errors.New("password must be at most 72 bytes") } return nil } func (s *Server) handleAuthState(w http.ResponseWriter, r *http.Request) { n, err := s.st.UserCount(r.Context()) if err != nil { writeErr(w, 500, err) return } resp := map[string]any{"setup_required": n == 0, "authenticated": false} if u, _, err := s.currentUser(r); err == nil { resp["authenticated"] = true resp["username"] = u.Username } writeJSON(w, 200, resp) } func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { var c credentials if !readJSON(w, r, &c) { return } c.Username = strings.TrimSpace(c.Username) if c.Username == "" { writeErr(w, 400, errors.New("username is required")) return } if err := validPassword(c.Password); err != nil { writeErr(w, 400, err) return } hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), 12) if err != nil { writeErr(w, 500, err) return } if err := s.st.CreateFirstUser(r.Context(), c.Username, string(hash)); err != nil { writeErr(w, 409, err) return } u, err := s.st.UserByName(r.Context(), c.Username) if err != nil { writeErr(w, 500, err) return } if err := s.startSession(w, r, u.ID); err != nil { writeErr(w, 500, err) return } s.log.Info("initial user created", "user", u.Username) writeJSON(w, 200, map[string]any{"ok": true}) } // dummyHash keeps login timing similar for unknown usernames. var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("playlink-timing-equalizer"), 12) func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if !s.loginLimit.allow(clientIP(r)) { writeErr(w, http.StatusTooManyRequests, errors.New("too many attempts; try again in a few minutes")) return } var c credentials if !readJSON(w, r, &c) { return } u, err := s.st.UserByName(r.Context(), strings.TrimSpace(c.Username)) hash := dummyHash if err == nil { hash = []byte(u.PasswordHash) } if bcrypt.CompareHashAndPassword(hash, []byte(c.Password)) != nil || err != nil { s.log.Warn("failed login", "ip", clientIP(r)) writeErr(w, http.StatusUnauthorized, errors.New("invalid username or password")) return } if err := s.startSession(w, r, u.ID); err != nil { writeErr(w, 500, err) return } writeJSON(w, 200, map[string]any{"ok": true}) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(cookieName); err == nil { _ = s.st.DeleteSession(r.Context(), hashToken(c.Value)) } http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode}) writeJSON(w, 200, map[string]any{"ok": true}) } func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { var body struct { Current string `json:"current"` New string `json:"new"` } if !readJSON(w, r, &body) { return } u, th, err := s.currentUser(r) if err != nil { writeErr(w, 401, err) return } if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Current)) != nil { writeErr(w, 400, errors.New("current password is incorrect")) return } if err := validPassword(body.New); err != nil { writeErr(w, 400, err) return } hash, err := bcrypt.GenerateFromPassword([]byte(body.New), 12) if err != nil { writeErr(w, 500, err) return } if err := s.st.SetPassword(r.Context(), u.ID, string(hash)); err != nil { writeErr(w, 500, err) return } _ = s.st.DeleteUserSessions(r.Context(), u.ID, th) // sign out other devices writeJSON(w, 200, map[string]any{"ok": true}) }