package server import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "time" "jarl/internal/api" "jarl/internal/auth" "jarl/internal/model" "jarl/internal/store" ) func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { se, _ := r.Context().Value(keySession).(*model.Session) writeJSON(w, http.StatusOK, s.whoamiOf(p, se)) return nil } func (s *Server) changeOwnPassword(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if p.Token != nil { return forbidden("passwords can't be changed with an API token; log in to the web UI") } var in api.PasswordChange if err := decodeJSON(r, &in); err != nil { return err } u, err := s.store.GetUser(r.Context(), p.User.ID) if err != nil { return err } if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Current) { s.eng.Audit(r.Context(), p, "user.password", "user", u.ID, u.Username, "failure", "wrong current password") return &httpError{http.StatusForbidden, "wrong_password", "the current password is wrong"} } hash, err := auth.HashPassword(in.Password) if err != nil { return badRequest("%v", err) } u.PasswordHash = hash if err := s.store.UpdateUser(r.Context(), u); err != nil { return err } // Log out other sessions. keep := "" if se, ok := r.Context().Value(keySession).(*model.Session); ok { keep = se.IDHash } s.store.DeleteUserSessions(r.Context(), u.ID, keep) s.eng.Audit(r.Context(), p, "user.password", "user", u.ID, u.Username, "success", "") w.WriteHeader(http.StatusNoContent) return nil } // grantsFrom builds the grant list from structured grants, compact // strings or a role template. func grantsFrom(grants *[]model.Grant, strs *[]string, role string) ([]model.Grant, bool, error) { var out []model.Grant set := false if role != "" { t, ok := auth.RoleTemplates[role] if !ok { var names []string for n := range auth.RoleTemplates { names = append(names, n) } return nil, false, badRequest("unknown role %q (known: %s)", role, strings.Join(names, ", ")) } out = append(out, t...) set = true } if grants != nil { for _, g := range *grants { if err := auth.ValidateGrant(&g); err != nil { return nil, false, badRequest("%v", err) } out = append(out, g) } set = true } if strs != nil { for _, gs := range *strs { g, err := auth.ParseGrant(gs) if err != nil { return nil, false, badRequest("grant %q: %v", gs, err) } out = append(out, g) } set = true } if set && out == nil { out = []model.Grant{} } return out, set, nil } func parseExpiry(in api.TokenInput) (*time.Time, error) { if in.ExpiresAt != nil { if in.ExpiresAt.Before(time.Now()) { return nil, badRequest("expires_at is in the past") } return in.ExpiresAt, nil } if in.ExpiresIn == "" { return nil, nil } d, err := parseDays(in.ExpiresIn) if err != nil || d <= 0 { return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn) } t := time.Now().Add(d) return &t, nil } // parseDays parses a duration in Go syntax ("720h", "15m") or in days // ("90d"). func parseDays(v string) (time.Duration, error) { v = strings.TrimSpace(v) if n, ok := strings.CutSuffix(v, "d"); ok { days, err := strconv.Atoi(n) if err != nil || days < 0 { return 0, fmt.Errorf("invalid duration %q", v) } return time.Duration(days) * 24 * time.Hour, nil } return time.ParseDuration(v) } func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error { var in api.TokenInput if err := decodeJSON(r, &in); err != nil { return err } if strings.TrimSpace(in.Name) == "" { return badRequest("a token needs a name") } grants, set, err := grantsFrom(in.Grants, in.GrantStrings, in.Role) if err != nil { return err } exp, err := parseExpiry(in) if err != nil { return err } // A restricted token can only create tokens that are no wider than // itself. if p.Token != nil && p.Token.Grants != nil { if !set { grants = p.Token.Grants } else if !narrower(grants, p.Token.Grants) { return forbidden("a restricted token can only create tokens with a subset of its own grants") } } secret, pid, hash := auth.NewToken() t := &model.Token{UserID: u.ID, Name: strings.TrimSpace(in.Name), PublicID: pid, SecretHash: hash, ExpiresAt: exp} if set { t.Grants = grants } if err := s.store.CreateToken(r.Context(), t); err != nil { return err } t.Username = u.Username s.eng.Audit(r.Context(), p, "token.create", "token", t.ID, u.Username+"/"+t.Name, "success", "") out := presentToken(t) out.Secret = secret writeJSON(w, http.StatusCreated, out) return nil } // narrower reports whether every grant in a is covered by a grant in b // (a conservative check: identical or b is unfiltered for the resource). func narrower(a, b []model.Grant) bool { for _, g := range a { ok := false for _, h := range b { if (h.Resource == auth.ResAll || h.Resource == g.Resource) && (h.Access == auth.Write || g.Access == auth.Read) && len(h.Zones) == 0 && len(h.Views) == 0 && len(h.Names) == 0 && len(h.Types) == 0 { ok = true break } if auth.FormatGrant(g) == auth.FormatGrant(h) { ok = true break } } if !ok { return false } } return true } func (s *Server) createOwnToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { return s.createTokenFor(w, r, p, p.User) } func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, userID int64) error { ts, err := s.store.ListTokens(r.Context(), userID) if err != nil { return err } out := []api.Token{} for _, t := range ts { if t.RevokedAt != nil && !queryBool(r, "all") { continue } out = append(out, presentToken(t)) } writeJSON(w, http.StatusOK, out) return nil } func (s *Server) listOwnTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { return s.listTokens(w, r, p.User.ID) } func (s *Server) listAllTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := requireAdmin(p); err != nil { return err } return s.listTokens(w, r, 0) } func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { id, err := pathID(r, "id") if err != nil { return err } t, err := s.store.GetToken(r.Context(), id) if err != nil { return err } if t.UserID != p.User.ID && !p.IsAdmin() { return notFound("token %d not found", id) } if err := s.store.RevokeToken(r.Context(), id); err != nil { if errors.Is(err, store.ErrNotFound) { return conflict("token %d is already revoked", id) } return err } s.eng.Audit(r.Context(), p, "token.revoke", "token", t.ID, t.Username+"/"+t.Name, "success", "") w.WriteHeader(http.StatusNoContent) return nil } // maxRotationGrace is the longest an old secret may keep working. const maxRotationGrace = 7 * 24 * time.Hour // rotateToken: POST /tokens/{id}/rotate gives a token a new secret. The // token keeps its ID, name, user and grants, so nothing needs to be set up // again; only the clients that use it need the new secret. The old secret // stops working at once, or after the grace period. A token that expires // gets the same lifetime again, counted from now, unless expires_in or // expires_at says otherwise. func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { id, err := pathID(r, "id") if err != nil { return err } var in api.TokenRotation body, err := readBody(r) if err != nil { return err } if body != nil { dec := json.NewDecoder(bytes.NewReader(body)) dec.DisallowUnknownFields() if err := dec.Decode(&in); err != nil { return badRequest("invalid JSON: %v", err) } } t, err := s.store.GetToken(r.Context(), id) if err != nil { if errors.Is(err, store.ErrNotFound) { return notFound("token %d not found", id) } return err } if t.UserID != p.User.ID && !p.IsAdmin() { return notFound("token %d not found", id) } // A restricted token may rotate itself, and tokens no wider than // itself, but not a wider token of the same user: it would learn that // token's new secret. if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !narrower(t.Grants, p.Token.Grants)) { return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants") } if t.RevokedAt != nil { return conflict("token %d is revoked; create a new one", id) } now := time.Now() var exp *time.Time switch { case in.ExpiresIn != "" || in.ExpiresAt != nil: if in.NoExpiry { return badRequest("no_expiry can't be combined with expires_in or expires_at") } if exp, err = parseExpiry(api.TokenInput{ExpiresIn: in.ExpiresIn, ExpiresAt: in.ExpiresAt}); err != nil { return err } case in.NoExpiry: case t.ExpiresAt != nil: life := t.ExpiresAt.Sub(t.IssuedAt()) if life <= 0 { return badRequest("can't tell the token's lifetime; give expires_in") } e := now.Add(life) exp = &e } var prevUntil *time.Time if in.Grace != "" { g, err := parseDays(in.Grace) if err != nil || g < 0 || g > maxRotationGrace { return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace) } until := now.Add(g) if t.ExpiresAt != nil && t.ExpiresAt.Before(until) { until = *t.ExpiresAt // the old secret never outlives its token } if g > 0 && until.After(now) { prevUntil = &until } } secret, hash := auth.RotateToken(t.PublicID) if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil { if errors.Is(err, store.ErrNotFound) { return conflict("token %d is revoked; create a new one", id) } return err } t, err = s.store.GetToken(r.Context(), id) if err != nil { return err } detail := "old secret stops working now" if prevUntil != nil { detail = "old secret works until " + prevUntil.UTC().Format(time.RFC3339) } if t.ExpiresAt != nil { detail += "; expires " + t.ExpiresAt.UTC().Format(time.RFC3339) } s.eng.Audit(r.Context(), p, "token.rotate", "token", t.ID, t.Username+"/"+t.Name, "success", detail) out := presentToken(t) out.Secret = secret writeJSON(w, http.StatusOK, out) return nil } func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := requireAdmin(p); err != nil { return err } us, err := s.store.ListUsers(r.Context()) if err != nil { return err } out := []api.User{} for _, u := range us { out = append(out, presentUser(u)) } writeJSON(w, http.StatusOK, out) return nil } func validUsername(n string) error { if len(n) < 1 || len(n) > 64 { return badRequest("usernames must be 1 to 64 characters") } for _, c := range n { if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("._-@", c)) { return badRequest("usernames may contain letters, digits, '.', '_', '-' and '@'") } } return nil } func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := requireAdmin(p); err != nil { return err } var in api.UserInput if err := decodeJSON(r, &in); err != nil { return err } if in.Username == nil { return badRequest("a user needs a username") } u := &model.User{Username: strings.TrimSpace(*in.Username)} if err := validUsername(u.Username); err != nil { return err } if err := applyUserInput(u, &in); err != nil { return err } if err := s.store.CreateUser(r.Context(), u); err != nil { if errors.Is(err, store.ErrConflict) { return conflict("user %s already exists", u.Username) } return err } s.eng.Audit(r.Context(), p, "user.create", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; ")) writeJSON(w, http.StatusCreated, presentUser(u)) return nil } func applyUserInput(u *model.User, in *api.UserInput) error { role := "" if in.Role != nil { role = *in.Role } grants, set, err := grantsFrom(in.Grants, in.GrantStrings, role) if err != nil { return err } if set { u.Grants = grants } if in.Admin != nil { u.Admin = *in.Admin } if in.Disabled != nil { u.Disabled = *in.Disabled } if in.Comment != nil { u.Comment = *in.Comment } if in.Password != nil { if *in.Password == "" { u.PasswordHash = "" // service account: tokens only } else { h, err := auth.HashPassword(*in.Password) if err != nil { return badRequest("%v", err) } u.PasswordHash = h } } return nil } func (s *Server) userByPath(r *http.Request) (*model.User, error) { id, err := pathID(r, "id") if err != nil { return nil, err } u, err := s.store.GetUser(r.Context(), id) if errors.Is(err, store.ErrNotFound) { return nil, notFound("user %d not found", id) } return u, err } func (s *Server) getUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { u, err := s.userByPath(r) if err != nil { return err } if u.ID != p.User.ID && !p.IsAdmin() { return forbidden("only administrators can see other users") } writeJSON(w, http.StatusOK, presentUser(u)) return nil } func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := requireAdmin(p); err != nil { return err } u, err := s.userByPath(r) if err != nil { return err } var in api.UserInput if err := decodeJSON(r, &in); err != nil { return err } wasAdmin := u.Admin && !u.Disabled if in.Username != nil { u.Username = strings.TrimSpace(*in.Username) if err := validUsername(u.Username); err != nil { return err } } if err := applyUserInput(u, &in); err != nil { return err } if wasAdmin && (!u.Admin || u.Disabled) { if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 { return conflict("%s is the last administrator; make someone else an administrator first", u.Username) } } if err := s.store.UpdateUser(r.Context(), u); err != nil { if errors.Is(err, store.ErrConflict) { return conflict("user %s already exists", u.Username) } return err } if u.Disabled || in.Password != nil { s.store.DeleteUserSessions(r.Context(), u.ID, "") } s.eng.Audit(r.Context(), p, "user.update", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; ")) writeJSON(w, http.StatusOK, presentUser(u)) return nil } func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := requireAdmin(p); err != nil { return err } u, err := s.userByPath(r) if err != nil { return err } if u.ID == p.User.ID { return conflict("you can't delete yourself") } if u.Admin && !u.Disabled { if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 { return conflict("%s is the last administrator", u.Username) } } if err := s.store.DeleteUser(r.Context(), u.ID); err != nil { return err } s.eng.Audit(r.Context(), p, "user.delete", "user", u.ID, u.Username, "success", "") w.WriteHeader(http.StatusNoContent) return nil } func (s *Server) listUserTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { u, err := s.userByPath(r) if err != nil { return err } if u.ID != p.User.ID && !p.IsAdmin() { return forbidden("only administrators can see other users' tokens") } return s.listTokens(w, r, u.ID) } func (s *Server) createUserToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { u, err := s.userByPath(r) if err != nil { return err } if u.ID != p.User.ID && !p.IsAdmin() { return forbidden("only administrators can create tokens for other users") } return s.createTokenFor(w, r, p, u) }