Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ The API image compiles `github.com/aws/aws-sdk-go-v2/service/ec2`. Give Docker a

The console signs people in with an email and password. While no admin exists, the page asks you to create one. Keep the API port private until you do. Set `OM_ADMIN_EMAIL` when that first address should be fixed in advance. `om admin create --email you@example.com` does the same from the machine before the port is reachable.

An admin adds the next person from the console. That person gets a temporary password, shown once, and chooses their own at the next sign-in. SMTP is optional; when it is set, the temporary password is emailed too. Google and GitHub buttons appear only when those client credentials are set, and the provider email must match an existing admin. Every console account is an admin. Two-factor authentication is optional per admin.
An admin adds the next person from the console. That person gets a temporary password, shown once, and chooses their own at the next sign-in. SMTP is optional. When it is set, the temporary password is emailed, and a forgotten password can be reset from a one-hour link. Google and GitHub buttons appear only when those client credentials are set, and the provider email must match an existing admin. Every console account is an admin. Two-factor authentication is optional per admin.

Each admin gets a personal API token, shown once in the console. Collectors, CI, and `curl` send it as `Authorization: Bearer` or `X-API-Key` on every `/v1` call except health, the session probe, and logout. The CLI talks to Postgres and does not use the token. A token cannot add or remove admins.

Expand Down
1 change: 1 addition & 0 deletions deploy/helm/opensourceom/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ api:
github:
clientId: ""
clientSecret: ""
# Optional. Sends temporary admin passwords and password-reset links.
smtp:
host: ""
port: 587
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/005-console-admins.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ People using the console need a login. Scripts that call the HTTP API need a cre

- The console signs people in with an email and password. Passwords are stored as Argon2id hashes. There is no shared deployment secret.
- While no admin exists, the console shows a setup form. `OM_ADMIN_EMAIL`, when set, is the only address that form accepts. `om admin create --email` creates that account from the machine before the port is reachable. Keep the port private until an admin exists.
- An admin adds the next admin by email. The new account gets a temporary password, shown once in the console, and must choose a new password at the next sign-in. SMTP is optional. When it is configured, the temporary password is emailed as well.
- An admin adds the next admin by email. The new account gets a temporary password, shown once in the console, and must choose a new password at the next sign-in. SMTP is optional. When it is configured, the temporary password is emailed as well, and the sign-in page can email a password-reset link. The link works once, expires in one hour, and does not say whether the address is an admin. Using it sets a new password and ends that admin's sessions. Personal API tokens stay valid. Without SMTP, the page does not offer reset. `om admin reset-password --email` still sets a password from the machine.
- Google and GitHub are optional. Buttons appear only when that provider's client id and secret are set. A verified email from the provider must match an existing admin. A new provider account does not become an admin on its own.
- Each admin can turn on TOTP two-factor authentication. Recovery codes are shown once. It is not required to finish setup.
- Google accounts must have a verified email. GitHub login requests `user:email` and accepts any verified address.
Expand Down
15 changes: 11 additions & 4 deletions internal/api/console_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type ConsoleAuth struct {
Providers map[string]auth.Provider
SetupEmail string
SendPassword func(ctx context.Context, to, password string) error
SendReset func(ctx context.Context, to, resetURL string) error
}

func (s *Server) WithConsoleAuth(cfg ConsoleAuth) *Server {
Expand All @@ -43,6 +44,7 @@ func (s *Server) WithConsoleAuth(cfg ConsoleAuth) *Server {
s.providers = cfg.Providers
s.setupEmail = strings.TrimSpace(cfg.SetupEmail)
s.sendPassword = cfg.SendPassword
s.sendReset = cfg.SendReset
s.secureCookies = strings.HasPrefix(s.publicURL, "https://")
if s.loginLimit == nil {
s.loginLimit = newLoginLimiter()
Expand All @@ -67,10 +69,11 @@ func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) {
return
}
body := map[string]any{
"authenticated": false,
"login_enabled": true,
"needs_setup": count == 0,
"providers": s.providerNames(),
"authenticated": false,
"login_enabled": true,
"needs_setup": count == 0,
"password_reset": s.sendReset != nil,
"providers": s.providerNames(),
}
if count == 0 && s.setupEmail != "" {
body["setup_email"] = s.setupEmail
Expand Down Expand Up @@ -464,6 +467,10 @@ func publicV1(r *http.Request) bool {
return true
case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/totp":
return true
case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/forgot":
return true
case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/reset":
return true
default:
return false
}
Expand Down
163 changes: 163 additions & 0 deletions internal/api/console_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,101 @@ func TestConsoleLoginRejectsUnknownAccount(t *testing.T) {
}
}

func TestForgotPasswordResetsTheAccount(t *testing.T) {
directory := newMemoryDirectory()
var resetTo, resetURL string
var sent int
handler := NewServer(nil).WithConsoleAuth(ConsoleAuth{
Directory: directory,
PublicURL: "https://om.example",
SendReset: func(_ context.Context, to, link string) error {
sent++
resetTo, resetURL = to, link
return nil
},
}).Handler()

setup := httptest.NewRequest(http.MethodPost, "/v1/auth/setup", strings.NewReader(`{"email":"ada@example.com","password":"correct-horse-battery"}`))
setupRec := httptest.NewRecorder()
handler.ServeHTTP(setupRec, setup)
if setupRec.Code != http.StatusOK {
t.Fatalf("setup = %d %s", setupRec.Code, setupRec.Body.String())
}
session := cookieNamed(setupRec.Result().Cookies(), sessionCookie)

probe := httptest.NewRecorder()
handler.ServeHTTP(probe, httptest.NewRequest(http.MethodGet, "/v1/auth/session", nil))
if !strings.Contains(probe.Body.String(), `"password_reset":true`) {
t.Fatalf("session = %s", probe.Body.String())
}

missing := httptest.NewRecorder()
handler.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/v1/auth/forgot", strings.NewReader(`{"email":"missing@example.com"}`)))
if missing.Code != http.StatusOK || sent != 0 {
t.Fatalf("unknown email = %d sent %d", missing.Code, sent)
}

forgot := httptest.NewRecorder()
handler.ServeHTTP(forgot, httptest.NewRequest(http.MethodPost, "/v1/auth/forgot", strings.NewReader(`{"email":"Ada@Example.com"}`)))
if forgot.Code != http.StatusOK || sent != 1 || resetTo != "ada@example.com" {
t.Fatalf("forgot = %d sent %d to %s", forgot.Code, sent, resetTo)
}
parsed, err := url.Parse(resetURL)
if err != nil {
t.Fatal(err)
}
token := parsed.Query().Get("reset")
if token == "" || !strings.HasPrefix(resetURL, "https://om.example/login?reset=") {
t.Fatalf("reset url = %s", resetURL)
}

short := httptest.NewRecorder()
handler.ServeHTTP(short, httptest.NewRequest(http.MethodPost, "/v1/auth/reset", strings.NewReader(`{"token":"`+token+`","password":"short"}`)))
if short.Code != http.StatusBadRequest {
t.Fatalf("short password = %d %s", short.Code, short.Body.String())
}

reset := httptest.NewRecorder()
handler.ServeHTTP(reset, httptest.NewRequest(http.MethodPost, "/v1/auth/reset", strings.NewReader(`{"token":"`+token+`","password":"a-new-password-ok"}`)))
if reset.Code != http.StatusNoContent {
t.Fatalf("reset = %d %s", reset.Code, reset.Body.String())
}

oldSession := httptest.NewRecorder()
rules := httptest.NewRequest(http.MethodGet, "/v1/rules", nil)
rules.AddCookie(session)
handler.ServeHTTP(oldSession, rules)
if oldSession.Code != http.StatusUnauthorized {
t.Fatalf("old session = %d", oldSession.Code)
}

reuse := httptest.NewRecorder()
handler.ServeHTTP(reuse, httptest.NewRequest(http.MethodPost, "/v1/auth/reset", strings.NewReader(`{"token":"`+token+`","password":"another-password-ok"}`)))
if reuse.Code != http.StatusBadRequest {
t.Fatalf("reuse = %d %s", reuse.Code, reuse.Body.String())
}

oldLogin := httptest.NewRecorder()
handler.ServeHTTP(oldLogin, httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"ada@example.com","password":"correct-horse-battery"}`)))
if oldLogin.Code != http.StatusUnauthorized {
t.Fatalf("old password = %d", oldLogin.Code)
}
newLogin := httptest.NewRecorder()
handler.ServeHTTP(newLogin, httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"ada@example.com","password":"a-new-password-ok"}`)))
if newLogin.Code != http.StatusOK {
t.Fatalf("new password = %d %s", newLogin.Code, newLogin.Body.String())
}
}

func TestForgotPasswordRequiresMail(t *testing.T) {
handler := NewServer(nil).WithConsoleAuth(ConsoleAuth{Directory: newMemoryDirectory()}).Handler()
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/auth/forgot", strings.NewReader(`{"email":"ada@example.com"}`)))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d %s", recorder.Code, recorder.Body.String())
}
}

func TestAuthSessionStaysOpen(t *testing.T) {
handler := (&Server{}).Handler()
recorder := httptest.NewRecorder()
Expand Down Expand Up @@ -170,9 +265,15 @@ type memoryDirectory struct {
previews map[string]string
passwords map[string]string
challenges map[string]string
resets map[string]memoryReset
plainToken string
}

type memoryReset struct {
adminID string
expires time.Time
}

type memoryInvite struct {
expires time.Time
consumed bool
Expand All @@ -187,6 +288,7 @@ func newMemoryDirectory() *memoryDirectory {
previews: map[string]string{},
passwords: map[string]string{},
challenges: map[string]string{},
resets: map[string]memoryReset{},
}
}

Expand Down Expand Up @@ -536,6 +638,67 @@ func (d *memoryDirectory) ChangePassword(_ context.Context, adminID, current, ne
return auth.ErrNotFound
}

func (d *memoryDirectory) RequestPasswordReset(_ context.Context, email string) (string, error) {
email, err := auth.NormalizeEmail(email)
if err != nil {
return "", nil
}
d.mu.Lock()
defer d.mu.Unlock()
var admin auth.Admin
found := false
for _, candidate := range d.admins {
if candidate.Provider == auth.ProviderPassword && strings.EqualFold(candidate.Email, email) && d.passwords[email] != "" {
admin = candidate
found = true
break
}
}
if !found {
return "", nil
}
for hash, reset := range d.resets {
if reset.adminID == admin.ID {
delete(d.resets, hash)
}
}
token := "reset-" + admin.ID
d.resets[auth.HashToken(token)] = memoryReset{adminID: admin.ID, expires: time.Now().Add(time.Hour)}
return token, nil
}

func (d *memoryDirectory) CompletePasswordReset(_ context.Context, token, password string) error {
if err := auth.ValidatePassword(password); err != nil {
return err
}
if token == "" || len(token) > 128 {
return auth.ErrResetInvalid
}
d.mu.Lock()
defer d.mu.Unlock()
key := auth.HashToken(token)
reset, ok := d.resets[key]
if !ok || !reset.expires.After(time.Now()) {
delete(d.resets, key)
return auth.ErrResetInvalid
}
delete(d.resets, key)
for i, admin := range d.admins {
if admin.ID != reset.adminID {
continue
}
d.passwords[admin.Email] = password
d.admins[i].MustChangePassword = false
for hash, id := range d.sessions {
if id == admin.ID {
delete(d.sessions, hash)
}
}
return nil
}
return auth.ErrResetInvalid
}

func (d *memoryDirectory) SetPassword(ctx context.Context, email, password string) error {
admin, found, err := d.FindByEmail(ctx, email)
if err != nil || !found {
Expand Down
78 changes: 78 additions & 0 deletions internal/api/password_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ package api
import (
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"strings"
"time"

"github.com/OpenSourceOM/core/internal/auth"
Expand Down Expand Up @@ -133,6 +136,81 @@ func (s *Server) handleAuthTOTP(w http.ResponseWriter, r *http.Request) {
s.startSession(w, r, admin)
}

func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if s.admins == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "console login is not available"})
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Enter an email address."})
return
}
if s.tooManyAttempts(r, "reset") || s.tooManyAttempts(r, "reset:"+req.Email) {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "Too many attempts. Try again later."})
return
}
s.noteFailure(r, "reset")
s.noteFailure(r, "reset:"+req.Email)
if s.sendReset == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Password reset email is not configured."})
return
}
email, err := auth.NormalizeEmail(req.Email)
if err == nil {
token, resetErr := s.admins.RequestPasswordReset(r.Context(), email)
if resetErr != nil {
writeAdminStoreError(w, resetErr)
return
}
if token != "" {
if err := s.sendReset(r.Context(), email, resetLink(s.publicURL, token)); err != nil {
log.Printf("password reset email: %v", err)
}
}
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}

func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if s.admins == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "console login is not available"})
return
}
var req struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Enter a new password."})
return
}
if s.tooManyAttempts(r, "reset-token") {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "Too many attempts. Try again later."})
return
}
err := s.admins.CompletePasswordReset(r.Context(), req.Token, req.Password)
switch {
case err == nil:
w.WriteHeader(http.StatusNoContent)
case errors.Is(err, auth.ErrPasswordShort):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Use at least 12 characters."})
case errors.Is(err, auth.ErrResetInvalid):
s.noteFailure(r, "reset-token")
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "That reset link is not valid. Request a new one."})
default:
writeAdminStoreError(w, err)
}
}

func resetLink(publicURL, token string) string {
return strings.TrimRight(publicURL, "/") + "/login?reset=" + url.QueryEscape(token)
}

func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
admin, ok := s.requireAdmin(w, r)
Expand Down
5 changes: 4 additions & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Server struct {
providers map[string]auth.Provider
setupEmail string
sendPassword func(ctx context.Context, to, password string) error
sendReset func(ctx context.Context, to, resetURL string) error
loginLimit *loginLimiter
secureCookies bool
}
Expand Down Expand Up @@ -74,6 +75,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /v1/auth/login", s.handleAuthLogin)
mux.HandleFunc("POST /v1/auth/totp", s.handleAuthTOTP)
mux.HandleFunc("POST /v1/auth/password", s.handleChangePassword)
mux.HandleFunc("POST /v1/auth/forgot", s.handleForgotPassword)
mux.HandleFunc("POST /v1/auth/reset", s.handleResetPassword)
mux.HandleFunc("POST /v1/auth/totp/setup", s.handleTOTPSetup)
mux.HandleFunc("POST /v1/auth/totp/confirm", s.handleTOTPConfirm)
mux.HandleFunc("POST /v1/auth/totp/disable", s.handleTOTPDisable)
Expand All @@ -91,7 +94,7 @@ func (s *Server) Handler() http.Handler {
}

// requireAPIAuth demands a console session or that admin's API token on every
// /v1 route except health, the session probe, and logout. The HTML stays
// /v1 route except the public auth routes in publicV1. The HTML stays
// open so the login page can load. Admin management still requires a session.
func (s *Server) requireAPIAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading