Why: two questions about operating this turned up two gaps. "Where do I get the admin password" had no answer once the line scrolled out of the log, and "how do I set up OIDC" ran straight into a sign-in link that could not work. What: - /oidc/login with no parameters now signs a browser in and ends in a session cookie. It previously required the user code of a waiting client, so the link on the web sign-in page always failed. With a code it still approves a device, and the two paths no longer overlap: an empty user code matches no pending device. - POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults. Changing a password requires the current one, and the one-time code when a second factor is enrolled, and revokes that account's other sessions. Until now nothing could change a password, which made the advice printed at first start impossible to follow. - keeporgd -reset-admin, for when that first password was not kept: a new one printed once, any second factor cleared, sessions revoked. It writes the state document directly and says to stop the server first. - The client read stdin through a new buffered reader per prompt, so the first prompt swallowed the rest and anything asking twice could not be scripted. One shared reader now. A prompt for a password being set no longer reads $KEEPORG_PASSWORD either; that variable holds the password you sign in with, and taking it here would have set the new password to the old one. - HANDOVER gains a first-sign-in section and an OIDC setup section covering the redirect URI, the config block, both entry points and how an identity becomes an account. Proved: OIDC now has tests, against a stub provider that serves discovery, authorization, token and userinfo. They cover the web sign-in that was broken, device approval, linking an existing account by username, the refusal when auto_create is off, and a forged callback state. Eight more cover the password change. Checked by hand with the real binaries: the first-start password logs once and works, -reset-admin replaces it while the old one stops working, and `keeporg passwd` changes it from a pipe. Server 0.5.0, desktop clients 0.3.0.
445 lines
13 KiB
Go
445 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"cc.tiredsysadmin.keeporg/internal/secret"
|
|
"cc.tiredsysadmin.keeporg/internal/store"
|
|
"cc.tiredsysadmin.keeporg/internal/totp"
|
|
)
|
|
|
|
// MinPasswordLen is the shortest password the server will store.
|
|
const MinPasswordLen = 10
|
|
|
|
// deviceTTL bounds how long a pending device login stays open.
|
|
const deviceTTL = 10 * time.Minute
|
|
|
|
var (
|
|
errBadCredentials = errors.New("bad credentials")
|
|
errTOTPRequired = errors.New("totp required")
|
|
errTOTPInvalid = errors.New("totp invalid")
|
|
)
|
|
|
|
// checkPassword validates a username, password and second factor against the
|
|
// state. It is the one place password login is decided.
|
|
func checkPassword(st *store.State, username, password, code string) (*store.User, error) {
|
|
u := st.UserByName(username)
|
|
if u == nil || u.Disabled || u.PassHash == "" {
|
|
return nil, errBadCredentials
|
|
}
|
|
if !secret.Verify(u.PassHash, password) {
|
|
return nil, errBadCredentials
|
|
}
|
|
if u.TOTPConfirmed {
|
|
if strings.TrimSpace(code) == "" {
|
|
return nil, errTOTPRequired
|
|
}
|
|
if !totp.Check(u.TOTPSecret, code, time.Now()) {
|
|
return nil, errTOTPInvalid
|
|
}
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
type loginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
TOTP string `json:"totp"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
type loginResponse struct {
|
|
Token string `json:"token"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
User userView `json:"user"`
|
|
}
|
|
|
|
type userView struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email,omitempty"`
|
|
ServerAdmin bool `json:"server_admin"`
|
|
TOTPEnabled bool `json:"totp_enabled"`
|
|
}
|
|
|
|
func view(u *store.User) userView {
|
|
return userView{ID: u.ID, Username: u.Username, Email: u.Email,
|
|
ServerAdmin: u.ServerAdmin, TOTPEnabled: u.TOTPConfirmed}
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req loginRequest
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var resp loginResponse
|
|
err := s.st.Update(func(st *store.State) error {
|
|
u, err := checkPassword(st, req.Username, req.Password, req.TOTP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sess := s.newSession(st, u.ID, req.Label)
|
|
resp = loginResponse{Token: sess.Token, ExpiresAt: sess.ExpiresAt, User: view(u)}
|
|
return nil
|
|
})
|
|
switch {
|
|
case errors.Is(err, errTOTPRequired):
|
|
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
|
|
case errors.Is(err, errTOTPInvalid):
|
|
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
|
|
case errors.Is(err, errBadCredentials):
|
|
fail(w, http.StatusUnauthorized, "bad_credentials", "unknown user or wrong password")
|
|
case err != nil:
|
|
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
|
default:
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, _ *store.User) {
|
|
tok := bearer(r)
|
|
if err := s.st.Update(func(st *store.State) error {
|
|
delete(st.Sessions, tok)
|
|
return nil
|
|
}); err != nil {
|
|
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
|
|
}
|
|
|
|
func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
type groupView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Role store.Role `json:"role"`
|
|
}
|
|
out := struct {
|
|
userView
|
|
Groups []groupView `json:"groups"`
|
|
}{userView: view(u), Groups: []groupView{}}
|
|
s.st.Read(func(st *store.State) {
|
|
for _, m := range st.Memberships {
|
|
if m.UserID != u.ID {
|
|
continue
|
|
}
|
|
if g := st.Groups[m.GroupID]; g != nil {
|
|
out.Groups = append(out.Groups, groupView{ID: g.ID, Name: g.Name, Role: m.Role})
|
|
}
|
|
}
|
|
})
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
sec := totp.NewSecret()
|
|
if err := s.st.Update(func(st *store.State) error {
|
|
target := st.Users[u.ID]
|
|
if target == nil {
|
|
return errBadCredentials
|
|
}
|
|
target.TOTPSecret = sec
|
|
target.TOTPConfirmed = false
|
|
return nil
|
|
}); err != nil {
|
|
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"secret": sec,
|
|
"uri": totp.URI("KeepOrg", u.Username, sec),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
err := s.st.Update(func(st *store.State) error {
|
|
target := st.Users[u.ID]
|
|
if target == nil || target.TOTPSecret == "" {
|
|
return errBadCredentials
|
|
}
|
|
if !totp.Check(target.TOTPSecret, req.Code, time.Now()) {
|
|
return errTOTPInvalid
|
|
}
|
|
target.TOTPConfirmed = true
|
|
return nil
|
|
})
|
|
switch {
|
|
case errors.Is(err, errTOTPInvalid):
|
|
fail(w, http.StatusBadRequest, "totp_invalid", "code rejected")
|
|
case err != nil:
|
|
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
|
default:
|
|
writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": true})
|
|
}
|
|
}
|
|
|
|
// ---- device login: the browser approves, the client polls ----
|
|
|
|
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Label string `json:"label"`
|
|
}
|
|
if r.ContentLength > 0 && !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
d := &store.DeviceAuth{
|
|
DeviceCode: secret.Token(),
|
|
UserCode: secret.UserCode(),
|
|
Label: req.Label,
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(deviceTTL),
|
|
}
|
|
if err := s.st.Update(func(st *store.State) error {
|
|
st.Devices[d.DeviceCode] = d
|
|
return nil
|
|
}); err != nil {
|
|
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"device_code": d.DeviceCode,
|
|
"user_code": d.UserCode,
|
|
"verification_url": s.cfg.ExternalURL + "/device?code=" + d.UserCode,
|
|
"interval": 2,
|
|
"expires_in": int(deviceTTL.Seconds()),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDevicePoll(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
DeviceCode string `json:"device_code"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var (
|
|
token string
|
|
userID string
|
|
gone bool
|
|
)
|
|
err := s.st.Update(func(st *store.State) error {
|
|
d := st.Devices[req.DeviceCode]
|
|
if d == nil || time.Now().After(d.ExpiresAt) {
|
|
delete(st.Devices, req.DeviceCode)
|
|
gone = true
|
|
return nil
|
|
}
|
|
if d.SessionToken == "" {
|
|
return nil
|
|
}
|
|
token = d.SessionToken
|
|
if sess := st.Sessions[token]; sess != nil {
|
|
userID = sess.UserID
|
|
}
|
|
delete(st.Devices, req.DeviceCode)
|
|
return nil
|
|
})
|
|
switch {
|
|
case err != nil:
|
|
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
|
case gone:
|
|
fail(w, http.StatusGone, "expired", "device code expired")
|
|
case token == "":
|
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "pending"})
|
|
default:
|
|
var resp loginResponse
|
|
s.st.Read(func(st *store.State) {
|
|
resp.Token = token
|
|
if sess := st.Sessions[token]; sess != nil {
|
|
resp.ExpiresAt = sess.ExpiresAt
|
|
}
|
|
if u := st.Users[userID]; u != nil {
|
|
resp.User = view(u)
|
|
}
|
|
})
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleDevicePage(w http.ResponseWriter, r *http.Request) {
|
|
s.renderDevice(w, r.URL.Query().Get("code"), "")
|
|
}
|
|
|
|
func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
code := strings.ToUpper(strings.TrimSpace(r.FormValue("code")))
|
|
err := s.st.Update(func(st *store.State) error {
|
|
d := st.DeviceByUserCode(code)
|
|
if d == nil || time.Now().After(d.ExpiresAt) {
|
|
return errors.New("unknown or expired code")
|
|
}
|
|
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
|
|
return nil
|
|
})
|
|
switch {
|
|
case errors.Is(err, errTOTPRequired):
|
|
s.renderDevice(w, code, "This account requires a one-time code.")
|
|
case errors.Is(err, errTOTPInvalid):
|
|
s.renderDevice(w, code, "That one-time code was rejected.")
|
|
case errors.Is(err, errBadCredentials):
|
|
s.renderDevice(w, code, "Unknown user or wrong password.")
|
|
case err != nil:
|
|
s.renderDevice(w, code, err.Error())
|
|
default:
|
|
s.renderDone(w, "Device approved", "You can close this page and return to the application.")
|
|
}
|
|
}
|
|
|
|
// changePassword replaces a user's password after proving the current one.
|
|
// It is the one place a password changes, so the rules live here: the
|
|
// current password is required even though the caller already holds a
|
|
// session, the second factor is required when one is enrolled, and every
|
|
// other session of that user is revoked, because a password change is what
|
|
// someone does when they think a session is no longer theirs.
|
|
func changePassword(st *store.State, username, current, totpCode, next, keepToken string) error {
|
|
u, err := checkPassword(st, username, current, totpCode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(next) < MinPasswordLen {
|
|
return fmt.Errorf("the new password must be at least %d characters", MinPasswordLen)
|
|
}
|
|
if next == current {
|
|
return errors.New("the new password is the same as the old one")
|
|
}
|
|
hash, err := secret.Hash(next)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u.PassHash = hash
|
|
for token, sess := range st.Sessions {
|
|
if sess.UserID == u.ID && token != keepToken {
|
|
delete(st.Sessions, token)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
CurrentPassword string `json:"current_password"`
|
|
NewPassword string `json:"new_password"`
|
|
TOTP string `json:"totp"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
err := s.st.Update(func(st *store.State) error {
|
|
return changePassword(st, u.Username, req.CurrentPassword, req.TOTP,
|
|
req.NewPassword, bearer(r))
|
|
})
|
|
switch {
|
|
case errors.Is(err, errTOTPRequired):
|
|
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
|
|
case errors.Is(err, errTOTPInvalid):
|
|
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
|
|
case errors.Is(err, errBadCredentials):
|
|
fail(w, http.StatusUnauthorized, "bad_credentials", "the current password is wrong")
|
|
case err != nil:
|
|
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
default:
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"status": "password changed; other sessions on this account were signed out",
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- invites ----
|
|
|
|
func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) {
|
|
tok := r.PathValue("token")
|
|
var ok bool
|
|
s.st.Read(func(st *store.State) {
|
|
if inv := st.Invites[tok]; inv != nil && inv.Usable(time.Now()) {
|
|
ok = true
|
|
}
|
|
})
|
|
if !ok {
|
|
s.renderDone(w, "Invite not valid", "This invite link has expired or has already been used.")
|
|
return
|
|
}
|
|
s.renderInvite(w, tok, "")
|
|
}
|
|
|
|
func (s *Server) handleInviteRedeem(w http.ResponseWriter, r *http.Request) {
|
|
tok := r.PathValue("token")
|
|
if err := r.ParseForm(); err != nil {
|
|
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
username := strings.TrimSpace(r.FormValue("username"))
|
|
password := r.FormValue("password")
|
|
err := s.st.Update(func(st *store.State) error {
|
|
inv := st.Invites[tok]
|
|
if inv == nil || !inv.Usable(time.Now()) {
|
|
return errors.New("this invite link has expired or has already been used")
|
|
}
|
|
if err := validUsername(username); err != nil {
|
|
return err
|
|
}
|
|
if len(password) < MinPasswordLen {
|
|
return errors.New("password must be at least 10 characters")
|
|
}
|
|
if password != r.FormValue("password2") {
|
|
return errors.New("the two passwords do not match")
|
|
}
|
|
if st.UserByName(username) != nil {
|
|
return errors.New("that username is taken")
|
|
}
|
|
hash, err := secret.Hash(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u := &store.User{ID: secret.ID(), Username: username, Email: strings.TrimSpace(r.FormValue("email")),
|
|
PassHash: hash, CreatedAt: time.Now()}
|
|
st.Users[u.ID] = u
|
|
if inv.GroupID != "" {
|
|
role := inv.Role
|
|
if !role.Valid() {
|
|
role = store.RoleMember
|
|
}
|
|
st.Memberships = append(st.Memberships, store.Membership{UserID: u.ID, GroupID: inv.GroupID, Role: role})
|
|
}
|
|
inv.Uses++
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
s.renderInvite(w, tok, err.Error())
|
|
return
|
|
}
|
|
s.renderDone(w, "Account created",
|
|
"Sign in from the KeepOrg client with your new username and password.")
|
|
}
|
|
|
|
func validUsername(name string) error {
|
|
if len(name) < 2 || len(name) > 64 {
|
|
return errors.New("username must be between 2 and 64 characters")
|
|
}
|
|
for _, r := range name {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
|
r == '.', r == '-', r == '_', r == '@':
|
|
default:
|
|
return errors.New("username may contain letters, digits and . - _ @ only")
|
|
}
|
|
}
|
|
return nil
|
|
}
|