Compare commits

...

2 Commits

Author SHA1 Message Date
hyung-hwan efe82944ab some refactoring with server identifier 2026-06-01 23:59:13 +09:00
hyung-hwan 8274a623e4 added totp support 2026-06-01 23:18:46 +09:00
40 changed files with 1612 additions and 116 deletions
+3
View File
@@ -16,6 +16,9 @@ codit-server.debug:
codit-data-browser: codit-data-browser:
CGO_ENABLED=0 go build -x -ldflags "-X 'main.CODIT_SERVER_NAME=$(NAME)' -X 'main.CODIT_SERVER_VERSION=$(VERSION)'" -o $@ $(CODIT_DATA_BROWSER_SRCS) CGO_ENABLED=0 go build -x -ldflags "-X 'main.CODIT_SERVER_NAME=$(NAME)' -X 'main.CODIT_SERVER_VERSION=$(VERSION)'" -o $@ $(CODIT_DATA_BROWSER_SRCS)
check:
go test ./...
clean: clean:
go clean -x go clean -x
rm -rf codit-server codit-server.debug codit-data-browser rm -rf codit-server codit-server.debug codit-data-browser
+3 -3
View File
@@ -55,20 +55,20 @@ func run() int {
flag.Var(&configPaths, "config-file-pattern", "wildcard pattern for YAML configuration files; repeatable") flag.Var(&configPaths, "config-file-pattern", "wildcard pattern for YAML configuration files; repeatable")
flag.Parse() flag.Parse()
logger = codit.NewAsyncLogger("codit", os.Stderr, codit.LOG_ALL) logger = codit.NewAsyncLogger(PROGRAM_NAME, os.Stderr, codit.LOG_ALL)
defer logger.Close() defer logger.Close()
ctx, stop = signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) ctx, stop = signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop() defer stop()
logger.Write("", codit.LOG_INFO, "starting %s %s", PROGRAM_NAME, PROGRAM_VERSION) logger.Write("", codit.LOG_INFO, "starting %s %s", PROGRAM_NAME, PROGRAM_VERSION)
defer logger.Write("", codit.LOG_INFO, "stopped %s %s", PROGRAM_NAME, PROGRAM_VERSION) defer logger.Write("", codit.LOG_INFO, "stopped %s %s", PROGRAM_NAME, PROGRAM_VERSION)
cfg, err = codit.LoadConfigFilesWithEnvPrefix(configPaths, "CODIT_") cfg, err = codit.LoadConfigFilesWithEnvPrefix(configPaths, codit.EnvPrefixFromServerIdentifier(PROGRAM_NAME))
if err != nil { if err != nil {
logger.Write("", codit.LOG_ERROR, "load config: %v", err) logger.Write("", codit.LOG_ERROR, "load config: %v", err)
return 1 return 1
} }
server, err = codit.NewServer(&cfg, logger) server, err = codit.NewServerWithIdentifier(&cfg, logger, PROGRAM_NAME)
if err != nil { if err != nil {
logger.Write("", codit.LOG_ERROR, "init app: %v", err) logger.Write("", codit.LOG_ERROR, "init app: %v", err)
return 1 return 1
+90
View File
@@ -0,0 +1,90 @@
package auth
import "crypto/hmac"
import "crypto/rand"
import "crypto/sha1"
import "encoding/base32"
import "encoding/binary"
import "fmt"
import "net/url"
import "strconv"
import "strings"
import "time"
const TOTPPeriod int64 = 30
const TOTPDigits int = 6
func NewTOTPSecret() (string, error) {
var raw []byte
var err error
var enc *base32.Encoding
raw = make([]byte, 20)
_, err = rand.Read(raw)
if err != nil {
return "", err
}
enc = base32.StdEncoding.WithPadding(base32.NoPadding)
return enc.EncodeToString(raw), nil
}
func TOTPProvisioningURL(issuer string, account string, secret string) string {
var label string
var values url.Values
label = strings.TrimSpace(issuer) + ":" + strings.TrimSpace(account)
values = url.Values{}
values.Set("secret", strings.TrimSpace(secret))
values.Set("issuer", strings.TrimSpace(issuer))
values.Set("algorithm", "SHA1")
values.Set("digits", strconv.Itoa(TOTPDigits))
values.Set("period", strconv.FormatInt(TOTPPeriod, 10))
return "otpauth://totp/" + url.PathEscape(label) + "?" + values.Encode()
}
func ValidateTOTP(code string, secret string, now time.Time) bool {
var value string
var counter int64
var i int64
value = strings.TrimSpace(code)
if len(value) != TOTPDigits {
return false
}
counter = now.Unix() / TOTPPeriod
for i = -1; i <= 1; i++ {
if totpCode(secret, counter+i) == value {
return true
}
}
return false
}
func totpCode(secret string, counter int64) string {
var key []byte
var msg []byte
var mac hashHMAC
var sum []byte
var offset byte
var binaryCode uint32
var code uint32
var enc *base32.Encoding
var err error
enc = base32.StdEncoding.WithPadding(base32.NoPadding)
key, err = enc.DecodeString(strings.ToUpper(strings.TrimSpace(secret)))
if err != nil {
return ""
}
msg = make([]byte, 8)
binary.BigEndian.PutUint64(msg, uint64(counter))
mac = hmac.New(sha1.New, key)
_, _ = mac.Write(msg)
sum = mac.Sum(nil)
offset = sum[len(sum)-1] & 0x0f
binaryCode = (uint32(sum[offset])&0x7f)<<24 | (uint32(sum[offset+1])&0xff)<<16 | (uint32(sum[offset+2])&0xff)<<8 | (uint32(sum[offset+3]) & 0xff)
code = binaryCode % 1000000
return fmt.Sprintf("%06d", code)
}
type hashHMAC interface {
Write([]byte) (int, error)
Sum([]byte) []byte
}
+29
View File
@@ -0,0 +1,29 @@
package auth
import "testing"
import "time"
func TestTOTPCodeRFC6238(t *testing.T) {
var secret string
var code string
secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
code = totpCode(secret, time.Unix(59, 0).Unix()/TOTPPeriod)
if code != "287082" {
t.Fatalf("unexpected TOTP code: %s", code)
}
}
func TestValidateTOTP(t *testing.T) {
var secret string
var now time.Time
var code string
secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
now = time.Unix(59, 0)
code = totpCode(secret, now.Unix()/TOTPPeriod)
if !ValidateTOTP(code, secret, now) {
t.Fatalf("expected TOTP code to validate")
}
if ValidateTOTP("000000", secret, now) {
t.Fatalf("unexpected TOTP validation")
}
}
+9 -8
View File
@@ -84,8 +84,8 @@ func (s *Store) GetUserByID(id string) (models.User, error) {
var created time.Time var created time.Time
var updated time.Time var updated time.Time
var err error var err error
row = s.QueryRow(`SELECT public_id, username, display_name, email, is_admin, disabled, auth_source, created_at, updated_at FROM users WHERE public_id = ?`, id) row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, COALESCE(t.enabled, 0), u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.public_id = ?`, id)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &created, &updated) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &created, &updated)
if err != nil { if err != nil {
return user, err return user, err
} }
@@ -101,8 +101,8 @@ func (s *Store) GetUserByUsername(username string) (models.User, string, error)
var err error var err error
var created time.Time var created time.Time
var updated time.Time var updated time.Time
row = s.QueryRow(`SELECT public_id, username, display_name, email, is_admin, disabled, auth_source, password_hash, created_at, updated_at FROM users WHERE username = ?`, username) row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, u.password_hash, COALESCE(t.enabled, 0), u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.username = ?`, username)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &created, &updated) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &user.TOTPEnabled, &created, &updated)
if err != nil { if err != nil {
return user, passwordHash.String, err return user, passwordHash.String, err
} }
@@ -118,13 +118,13 @@ func (s *Store) ListUsers() ([]models.User, error) {
var u models.User var u models.User
var created time.Time var created time.Time
var updated time.Time var updated time.Time
rows, err = s.Query(`SELECT public_id, username, display_name, email, is_admin, disabled, auth_source, created_at, updated_at FROM users ORDER BY username`) rows, err = s.Query(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, COALESCE(t.enabled, 0), u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id ORDER BY u.username`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthSource, &created, &updated) err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthSource, &u.TOTPEnabled, &created, &updated)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -874,11 +874,12 @@ func (s *Store) GetSessionUser(token string) (models.User, time.Time, error) {
var created time.Time var created time.Time
var updated time.Time var updated time.Time
row = s.QueryRow(` row = s.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, u.created_at, u.updated_at, s.expires_at SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, COALESCE(t.enabled, 0), u.created_at, u.updated_at, s.expires_at
FROM sessions s JOIN users u ON u.id = s.user_id FROM sessions s JOIN users u ON u.id = s.user_id
LEFT JOIN user_totp t ON t.user_id = u.id
WHERE s.token = ? AND u.disabled = 0 WHERE s.token = ? AND u.disabled = 0
`, token) `, token)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &created, &updated, &expires) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &created, &updated, &expires)
if err != nil { if err != nil {
return user, time.Time{}, err return user, time.Time{}, err
} }
+62
View File
@@ -0,0 +1,62 @@
package db
import "database/sql"
import "time"
type UserTOTP struct {
SecretEncrypted string
PendingSecretEncrypted string
Enabled bool
CreatedAt int64
UpdatedAt int64
}
func (s *Store) GetUserTOTP(userID string) (UserTOTP, error) {
var item UserTOTP
var row *sql.Row
var created time.Time
var updated time.Time
var err error
row = s.QueryRow(`
SELECT COALESCE(t.secret_encrypted, ''), COALESCE(t.pending_secret_encrypted, ''), COALESCE(t.enabled, 0), t.created_at, t.updated_at
FROM user_totp t JOIN users u ON u.id = t.user_id
WHERE u.public_id = ?
`, userID)
err = row.Scan(&item.SecretEncrypted, &item.PendingSecretEncrypted, &item.Enabled, &created, &updated)
if err != nil {
return item, err
}
item.CreatedAt = created.Unix()
item.UpdatedAt = updated.Unix()
return item, nil
}
func (s *Store) UpsertUserTOTPPending(userID string, pendingSecretEncrypted string) error {
var err error
var now time.Time
now = time.Now().UTC()
_, err = s.Exec(`
INSERT INTO user_totp (user_id, secret_encrypted, pending_secret_encrypted, enabled, created_at, updated_at)
VALUES ((SELECT id FROM users WHERE public_id = ?), '', ?, 0, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET pending_secret_encrypted = excluded.pending_secret_encrypted, updated_at = excluded.updated_at
`, userID, pendingSecretEncrypted, now, now)
return err
}
func (s *Store) EnableUserTOTP(userID string, secretEncrypted string) error {
var err error
var now time.Time
now = time.Now().UTC()
_, err = s.Exec(`
INSERT INTO user_totp (user_id, secret_encrypted, pending_secret_encrypted, enabled, created_at, updated_at)
VALUES ((SELECT id FROM users WHERE public_id = ?), ?, '', 1, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET secret_encrypted = excluded.secret_encrypted, pending_secret_encrypted = '', enabled = 1, updated_at = excluded.updated_at
`, userID, secretEncrypted, now, now)
return err
}
func (s *Store) DeleteUserTOTP(userID string) error {
var err error
_, err = s.Exec(`DELETE FROM user_totp WHERE user_id = (SELECT id FROM users WHERE public_id = ?)`, userID)
return err
}
+1 -1
View File
@@ -715,7 +715,7 @@ func (api *API) ensureACMEAccount(ctx context.Context, profile models.ACMEProfil
return nil, nil, profile, err return nil, nil, profile, err
} }
} }
client = &acme.Client{Key: key, DirectoryURL: strings.TrimSpace(profile.DirectoryURL), UserAgent: "codit-acme"} client = &acme.Client{Key: key, DirectoryURL: strings.TrimSpace(profile.DirectoryURL), UserAgent: api.serverIdentifier()+"-acme"}
if strings.TrimSpace(profile.AccountURL) != "" { if strings.TrimSpace(profile.AccountURL) != "" {
client.KID = acme.KeyID(strings.TrimSpace(profile.AccountURL)) client.KID = acme.KeyID(strings.TrimSpace(profile.AccountURL))
return client, key, profile, nil return client, key, profile, nil
+83 -7
View File
@@ -30,7 +30,26 @@ import "codit/internal/storage"
import "codit/internal/util" import "codit/internal/util"
import codit_logger "codit/logger" import codit_logger "codit/logger"
type APIOptions struct {
ServerIdentifier string
Cfg config.Config
Store *db.Store
Repos git.RepoManager
RpmBase string
RpmMeta *rpm.MetaManager
RpmMirror *rpm.MirrorManager
DockerBase string
Uploads storage.FileStore
Logger codit_logger.Logger
OnTLSListenersChanged func()
OnTLSListenerRuntimeStatus func() map[string]int
}
type API struct { type API struct {
ServerIdentifier string
ServerTitle string
SessionCookieName string
OIDCStateCookieName string
Cfg config.Config Cfg config.Config
Store *db.Store Store *db.Store
Repos git.RepoManager Repos git.RepoManager
@@ -47,11 +66,70 @@ type API struct {
OnTLSListenerRuntimeStatus func() map[string]int OnTLSListenerRuntimeStatus func() map[string]int
} }
func NewAPI(options APIOptions) *API {
var identifier string
var title string
var api *API
identifier = strings.ToLower(strings.TrimSpace(options.ServerIdentifier))
identifier = strings.ReplaceAll(identifier, "_", "-")
identifier = strings.Trim(identifier, "-")
if identifier == "" {
identifier = "codit"
}
title = strings.TrimSpace(options.ServerIdentifier)
if title == "" {
title = "Codit"
}
api = &API{
ServerIdentifier: identifier,
ServerTitle: title,
SessionCookieName: strings.ReplaceAll(identifier, "-", "_") + "_session",
OIDCStateCookieName: strings.ReplaceAll(identifier, "-", "_") + "_oidc_state",
Cfg: options.Cfg,
Store: options.Store,
Repos: options.Repos,
RpmBase: options.RpmBase,
RpmMeta: options.RpmMeta,
RpmMirror: options.RpmMirror,
DockerBase: options.DockerBase,
Uploads: options.Uploads,
Logger: options.Logger,
SSHSessionRegistry: NewSSHSessionRegistry(),
SSHPromptedAuthStore: NewSSHPromptedAuthStore(),
SSHPreparedSessionStore: NewSSHPreparedSessionStore(),
OnTLSListenersChanged: options.OnTLSListenersChanged,
OnTLSListenerRuntimeStatus: options.OnTLSListenerRuntimeStatus,
}
return api
}
func (api *API) serverIdentifier() string {
if strings.TrimSpace(api.ServerIdentifier) == "" { return "codit" }
return strings.TrimSpace(api.ServerIdentifier)
}
func (api *API) serverTitle() string {
if strings.TrimSpace(api.ServerTitle) == "" { return "Codit" }
return strings.TrimSpace(api.ServerTitle)
}
func (api *API) sessionCookieName() string {
if strings.TrimSpace(api.SessionCookieName) == "" { return "codit_session" }
return strings.TrimSpace(api.SessionCookieName)
}
func (api *API) oidcStateCookieName() string {
if strings.TrimSpace(api.OIDCStateCookieName) == "" { return "codit_oidc_state" }
return strings.TrimSpace(api.OIDCStateCookieName)
}
const logIDAuth string = "auth" const logIDAuth string = "auth"
type loginRequest struct { type loginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
OTPCode string `json:"otp_code"`
} }
type createUserRequest struct { type createUserRequest struct {
@@ -380,8 +458,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
return return
} }
if err == nil && storedHash != "" && auth.ComparePassword(storedHash, req.Password) == nil { if err == nil && storedHash != "" && auth.ComparePassword(storedHash, req.Password) == nil {
api.issueSession(w, r, user) api.finishLogin(w, r, user, req.OTPCode)
WriteJSON(w, http.StatusOK, user)
return return
} }
@@ -410,8 +487,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
user = created user = created
} }
} }
api.issueSession(w, r, user) api.finishLogin(w, r, user, req.OTPCode)
WriteJSON(w, http.StatusOK, user)
return return
} }
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap login failed username=%s err=%v", req.Username, err) api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap login failed username=%s err=%v", req.Username, err)
@@ -433,7 +509,7 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
_ = api.store(r).DeleteExpiredSessions(time.Now().UTC()) _ = api.store(r).DeleteExpiredSessions(time.Now().UTC())
_ = api.store(r).CreateSession(user.ID, token, expires) _ = api.store(r).CreateSession(user.ID, token, expires)
cookie = &http.Cookie{ cookie = &http.Cookie{
Name: "codit_session", Name: api.sessionCookieName(),
Value: token, Value: token,
HttpOnly: true, HttpOnly: true,
Path: "/", Path: "/",
@@ -445,12 +521,12 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var cookie *http.Cookie var cookie *http.Cookie
var err error var err error
cookie, err = r.Cookie("codit_session") cookie, err = r.Cookie(api.sessionCookieName())
if err == nil { if err == nil {
_ = api.store(r).DeleteSession(cookie.Value) _ = api.store(r).DeleteSession(cookie.Value)
} }
cookie = &http.Cookie{ cookie = &http.Cookie{
Name: "codit_session", Name: api.sessionCookieName(),
Value: "", Value: "",
Path: "/", Path: "/",
Expires: time.Unix(0, 0), Expires: time.Unix(0, 0),
+5 -5
View File
@@ -83,7 +83,7 @@ func (api *API) OIDCLogin(w http.ResponseWriter, r *http.Request, _ map[string]s
return return
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "codit_oidc_state", Name: api.oidcStateCookieName(),
Value: state, Value: state,
HttpOnly: true, HttpOnly: true,
Path: "/api/auth/oidc", Path: "/api/auth/oidc",
@@ -125,8 +125,8 @@ func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[strin
} }
state = strings.TrimSpace(r.URL.Query().Get("state")) state = strings.TrimSpace(r.URL.Query().Get("state"))
code = strings.TrimSpace(r.URL.Query().Get("code")) code = strings.TrimSpace(r.URL.Query().Get("code"))
cookie, err = r.Cookie("codit_oidc_state") cookie, err = r.Cookie(api.oidcStateCookieName())
clearOIDCStateCookie(w) api.clearOIDCStateCookie(w)
if err != nil || cookie.Value == "" || state == "" || state != cookie.Value { if err != nil || cookie.Value == "" || state == "" || state != cookie.Value {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid oidc state"}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid oidc state"})
return return
@@ -411,9 +411,9 @@ func newOIDCState() (string, error) {
return hex.EncodeToString(buf), nil return hex.EncodeToString(buf), nil
} }
func clearOIDCStateCookie(w http.ResponseWriter) { func (api *API) clearOIDCStateCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "codit_oidc_state", Name: api.oidcStateCookieName(),
Value: "", Value: "",
Path: "/api/auth/oidc", Path: "/api/auth/oidc",
Expires: time.Unix(0, 0), Expires: time.Unix(0, 0),
+5 -2
View File
@@ -337,7 +337,7 @@ func (api *API) CreatePKIRootCA(w http.ResponseWriter, r *http.Request, _ map[st
keyPEM = req.KeyPEM keyPEM = req.KeyPEM
_ = key _ = key
} else { } else {
certPEM, keyPEM, err = generateRootCA(strings.TrimSpace(req.CommonName), req.Days) certPEM, keyPEM, err = generateRootCA(strings.TrimSpace(req.CommonName), req.Days, api.serverTitle()+" Root CA")
if err != nil { if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
@@ -1209,7 +1209,7 @@ func parseTLSCertificatePair(certPEM string, keyPEM string) (*x509.Certificate,
return parsedCert, nil return parsedCert, nil
} }
func generateRootCA(commonName string, days int) (string, string, error) { func generateRootCA(commonName string, days int, defaultCommonName string) (string, string, error) {
var key *rsa.PrivateKey var key *rsa.PrivateKey
var now time.Time var now time.Time
var end time.Time var end time.Time
@@ -1219,6 +1219,9 @@ func generateRootCA(commonName string, days int) (string, string, error) {
var certPEM string var certPEM string
var keyPEM string var keyPEM string
var err error var err error
if strings.TrimSpace(commonName) == "" {
commonName = strings.TrimSpace(defaultCommonName)
}
if strings.TrimSpace(commonName) == "" { if strings.TrimSpace(commonName) == "" {
commonName = "Codit Root CA" commonName = "Codit Root CA"
} }
@@ -186,8 +186,8 @@ func (api *API) CreatePKIClientProfile(w http.ResponseWriter, r *http.Request, _
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization) req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization, api.serverTitle())
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix) req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix, api.serverIdentifier())
if err != nil { if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
@@ -274,8 +274,8 @@ func (api *API) UpdatePKIClientProfile(w http.ResponseWriter, r *http.Request, p
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization) req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization, api.serverTitle())
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix) req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix, api.serverIdentifier())
if err != nil { if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
@@ -792,23 +792,25 @@ func normalizePKIClientProfileTargets(raw []pkiClientProfileTargetRequest) ([]mo
return out, nil return out, nil
} }
func normalizePKIClientSubjectOrganization(raw string) string { func normalizePKIClientSubjectOrganization(raw string, defaultValue string) string {
var value string var value string
value = strings.TrimSpace(raw) value = strings.TrimSpace(raw)
if value == "" { if value == "" { return defaultValue }
return "Codit"
}
return value return value
} }
func normalizePKIClientSANURIPrefix(raw string) (string, error) { func normalizePKIClientSANURIPrefix(raw string, serverIdentifier string) (string, error) {
var value string var value string
var probe string var probe string
var err error var err error
value = strings.TrimSpace(raw) value = strings.TrimSpace(raw)
if value == "" { if value == "" {
value = "urn:codit:user:" serverIdentifier = strings.TrimSpace(serverIdentifier)
if serverIdentifier == "" {
serverIdentifier = "codit"
}
value = "urn:" + serverIdentifier + ":user:"
} }
probe = value + "probe" probe = value + "probe"
_, err = url.ParseRequestURI(probe) _, err = url.ParseRequestURI(probe)
+2 -2
View File
@@ -17,7 +17,7 @@ func TestBuildCRLPEMIncludesOddLengthHexSerials(t *testing.T) {
var entries []x509.RevocationListEntry var entries []x509.RevocationListEntry
var err error var err error
certPEM, keyPEM, err = generateRootCA("test-ca", 365) certPEM, keyPEM, err = generateRootCA("test-ca", 365, "Codit Root CA")
if err != nil { if err != nil {
t.Fatalf("generate root ca: %v", err) t.Fatalf("generate root ca: %v", err)
} }
@@ -68,7 +68,7 @@ func TestIssueCertFromCAUsesValidSeconds(t *testing.T) {
var err error var err error
var validity int64 var validity int64
certPEM, keyPEM, err = generateRootCA("test-ca", 365) certPEM, keyPEM, err = generateRootCA("test-ca", 365, "Codit Root CA")
if err != nil { if err != nil {
t.Fatalf("generate root ca: %v", err) t.Fatalf("generate root ca: %v", err)
} }
+8 -4
View File
@@ -786,7 +786,7 @@ func (api *API) DiscoverSSHServerHostKeyForSelf(w http.ResponseWriter, r *http.R
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
discovered, err = discoverSSHServerHostKey(item.Host, item.Port) discovered, err = discoverSSHServerHostKey(item.Host, item.Port, api.serverIdentifier())
if err != nil { if err != nil {
WriteJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return return
@@ -1028,7 +1028,7 @@ func (api *API) DiscoverSSHServerHostKeyAdmin(w http.ResponseWriter, r *http.Req
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
discovered, err = discoverSSHServerHostKey(item.Host, item.Port) discovered, err = discoverSSHServerHostKey(item.Host, item.Port, api.serverIdentifier())
if err != nil { if err != nil {
WriteJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return return
@@ -1736,7 +1736,7 @@ func parseSSHServerHostKey(raw string) (models.SSHServerHostKey, error) {
return item, nil return item, nil
} }
func discoverSSHServerHostKey(host string, port int) (models.SSHServerHostKey, error) { func discoverSSHServerHostKey(host string, port int, defaultUser string) (models.SSHServerHostKey, error) {
var item models.SSHServerHostKey var item models.SSHServerHostKey
var addr string var addr string
var conn net.Conn var conn net.Conn
@@ -1747,8 +1747,12 @@ func discoverSSHServerHostKey(host string, port int) (models.SSHServerHostKey, e
var err error var err error
addr = net.JoinHostPort(strings.TrimSpace(host), fmt.Sprintf("%d", port)) addr = net.JoinHostPort(strings.TrimSpace(host), fmt.Sprintf("%d", port))
defaultUser = strings.TrimSpace(defaultUser)
if defaultUser == "" {
defaultUser = "codit"
}
config = &ssh.ClientConfig{ config = &ssh.ClientConfig{
User: "codit", User: defaultUser,
Auth: []ssh.AuthMethod{}, Auth: []ssh.AuthMethod{},
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
discovered = key discovered = key
@@ -12,6 +12,14 @@ import "strings"
const sshSecretPrefix string = "enc:v1:" const sshSecretPrefix string = "enc:v1:"
func (api *API) encryptSSHSecretPayload(payload string) (string, error) { func (api *API) encryptSSHSecretPayload(payload string) (string, error) {
return api.encryptSecretPayload(payload)
}
func (api *API) decryptSSHSecretPayload(payload string) (string, error) {
return api.decryptSecretPayload(payload)
}
func (api *API) encryptSecretPayload(payload string) (string, error) {
var key []byte var key []byte
var block cipher.Block var block cipher.Block
var gcm cipher.AEAD var gcm cipher.AEAD
@@ -45,7 +53,7 @@ func (api *API) encryptSSHSecretPayload(payload string) (string, error) {
return sshSecretPrefix + base64.StdEncoding.EncodeToString(raw), nil return sshSecretPrefix + base64.StdEncoding.EncodeToString(raw), nil
} }
func (api *API) decryptSSHSecretPayload(payload string) (string, error) { func (api *API) decryptSecretPayload(payload string) (string, error) {
var key []byte var key []byte
var encoded string var encoded string
var raw []byte var raw []byte
+1 -1
View File
@@ -771,7 +771,7 @@ func (api *API) signSSHUserKeyWithCA(store *db.Store, item models.SSHUserCA, pub
return response, err return response, err
} }
if keyID == "" { if keyID == "" {
keyID = fmt.Sprintf("codit-%s-%d", item.Name, serial) keyID = fmt.Sprintf("%s-%s-%d", api.serverIdentifier(), item.Name, serial)
} }
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
cert = ssh.Certificate{ cert = ssh.Certificate{
@@ -17,6 +17,7 @@ type meResponse struct {
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"` AuthSource string `json:"auth_source"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
Permissions []string `json:"permissions,omitempty"` Permissions []string `json:"permissions,omitempty"`
@@ -172,6 +173,7 @@ func buildMeResponse(user models.User, permissions []string) meResponse {
IsAdmin: user.IsAdmin, IsAdmin: user.IsAdmin,
Disabled: user.Disabled, Disabled: user.Disabled,
AuthSource: user.AuthSource, AuthSource: user.AuthSource,
TOTPEnabled: user.TOTPEnabled,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt, UpdatedAt: user.UpdatedAt,
Permissions: permissions, Permissions: permissions,
+228
View File
@@ -0,0 +1,228 @@
package handlers
import "database/sql"
import "net/http"
import "strings"
import "time"
import "codit/internal/auth"
import "codit/internal/db"
import "codit/internal/middleware"
import "codit/internal/models"
type totpStatusResponse struct {
Enabled bool `json:"enabled"`
}
type totpSetupResponse struct {
Secret string `json:"secret"`
OTPAuthURL string `json:"otpauth_url"`
}
type totpEnableRequest struct {
OTPCode string `json:"otp_code"`
}
type totpDisableRequest struct {
Password string `json:"password"`
OTPCode string `json:"otp_code"`
}
func (api *API) finishLogin(w http.ResponseWriter, r *http.Request, user models.User, otpCode string) {
var ok bool
var err error
ok, err = api.verifyUserTOTPForLogin(r, user, otpCode)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !ok {
if strings.TrimSpace(otpCode) == "" {
WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": "totp_required", "totp_required": true})
return
}
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_totp"})
return
}
api.issueSession(w, r, user)
WriteJSON(w, http.StatusOK, user)
}
func (api *API) verifyUserTOTPForLogin(r *http.Request, user models.User, otpCode string) (bool, error) {
var item db.UserTOTP
var secret string
var err error
item, err = api.store(r).GetUserTOTP(user.ID)
if err != nil {
if err == sql.ErrNoRows {
return true, nil
}
return false, err
}
if !item.Enabled {
return true, nil
}
secret, err = api.decryptSecretPayload(item.SecretEncrypted)
if err != nil {
return false, err
}
return auth.ValidateTOTP(otpCode, secret, time.Now().UTC()), nil
}
func (api *API) GetMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var ctxUser models.User
var ok bool
var item db.UserTOTP
var err error
ctxUser, ok = middleware.UserFromContext(r.Context())
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
item, err = api.store(r).GetUserTOTP(ctxUser.ID)
if err != nil && err != sql.ErrNoRows {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: err == nil && item.Enabled})
}
func (api *API) SetupMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var ctxUser models.User
var ok bool
var secret string
var encrypted string
var url string
var err error
ctxUser, ok = middleware.UserFromContext(r.Context())
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
secret, err = auth.NewTOTPSecret()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
encrypted, err = api.encryptSecretPayload(secret)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
err = api.store(r).UpsertUserTOTPPending(ctxUser.ID, encrypted)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
url = auth.TOTPProvisioningURL(api.serverTitle(), ctxUser.Username, secret)
WriteJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, OTPAuthURL: url})
}
func (api *API) EnableMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var ctxUser models.User
var ok bool
var req totpEnableRequest
var item db.UserTOTP
var encrypted string
var secret string
var err error
ctxUser, ok = middleware.UserFromContext(r.Context())
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
item, err = api.store(r).GetUserTOTP(ctxUser.ID)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "totp setup required"})
return
}
encrypted = item.PendingSecretEncrypted
if encrypted == "" {
encrypted = item.SecretEncrypted
}
if encrypted == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "totp setup required"})
return
}
secret, err = api.decryptSecretPayload(encrypted)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !auth.ValidateTOTP(req.OTPCode, secret, time.Now().UTC()) {
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_totp"})
return
}
err = api.store(r).EnableUserTOTP(ctxUser.ID, encrypted)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: true})
}
func (api *API) DisableMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var ctxUser models.User
var ok bool
var req totpDisableRequest
var user models.User
var storedHash string
var err error
ctxUser, ok = middleware.UserFromContext(r.Context())
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
user, storedHash, err = api.store(r).GetUserByUsername(ctxUser.Username)
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
return
}
if storedHash != "" && auth.ComparePassword(storedHash, req.Password) != nil {
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid password"})
return
}
ok, err = api.verifyUserTOTPForLogin(r, user, req.OTPCode)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !ok {
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_totp"})
return
}
err = api.store(r).DeleteUserTOTP(ctxUser.ID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: false})
}
func (api *API) ResetUserTOTPAdmin(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
if !api.requireAdmin(w, r) {
return
}
_, err = api.store(r).GetUserByID(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
return
}
err = api.store(r).DeleteUserTOTP(params["id"])
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
+9 -1
View File
@@ -15,6 +15,14 @@ const userKey ctxKey = "user"
const principalKey ctxKey = "principal" const principalKey ctxKey = "principal"
func WithUser(store *db.Store, next http.Handler) http.Handler { func WithUser(store *db.Store, next http.Handler) http.Handler {
return WithUserCookie(store, "codit_session", next)
}
func WithUserCookie(store *db.Store, sessionCookieName string, next http.Handler) http.Handler {
sessionCookieName = strings.TrimSpace(sessionCookieName)
if sessionCookieName == "" {
sessionCookieName = "codit_session"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var cookie *http.Cookie var cookie *http.Cookie
var err error var err error
@@ -33,7 +41,7 @@ func WithUser(store *db.Store, next http.Handler) http.Handler {
if ok && requestStore != nil { if ok && requestStore != nil {
activeStore = requestStore activeStore = requestStore
} }
cookie, err = r.Cookie("codit_session") cookie, err = r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" { if err != nil || cookie.Value == "" {
token = apiKeyFromRequest(r) token = apiKeyFromRequest(r)
if token == "" { if token == "" {
+1
View File
@@ -8,6 +8,7 @@ type User struct {
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"` AuthSource string `json:"auth_source"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
+10
View File
@@ -23,6 +23,16 @@ CREATE TABLE IF NOT EXISTS sessions (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS user_totp (
user_id INTEGER PRIMARY KEY,
secret_encrypted TEXT NOT NULL DEFAULT '',
pending_secret_encrypted TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE, public_id TEXT NOT NULL UNIQUE,
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS user_totp (
user_id INTEGER PRIMARY KEY,
secret_encrypted TEXT NOT NULL DEFAULT '',
pending_secret_encrypted TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
+102 -39
View File
@@ -18,6 +18,7 @@ import "path/filepath"
import "sync" import "sync"
import "strings" import "strings"
import "time" import "time"
import "unicode"
import "codit/internal/auth" import "codit/internal/auth"
import codit_config "codit/config" import codit_config "codit/config"
@@ -313,6 +314,7 @@ func storageIDSegment(id int64) string {
type Server struct { type Server struct {
cfg *codit_config.Config cfg *codit_config.Config
logger Logger logger Logger
serverIdentifier string
store *db.Store store *db.Store
docker_base_dir string docker_base_dir string
@@ -331,6 +333,10 @@ type Server struct {
} }
func NewServer(cfg *Config, logger Logger) (*Server, error) { func NewServer(cfg *Config, logger Logger) (*Server, error) {
return NewServerWithIdentifier(cfg, logger, "codit")
}
func NewServerWithIdentifier(cfg *Config, logger Logger, identifier string) (*Server, error) {
if cfg == nil { if cfg == nil {
return nil, errors.New("config is required") return nil, errors.New("config is required")
} }
@@ -338,7 +344,7 @@ func NewServer(cfg *Config, logger Logger) (*Server, error) {
return nil, errors.New("logger is required") return nil, errors.New("logger is required")
} }
return newServerWithInternalConfig(cfg, logger) return newServerWithInternalConfig(cfg, logger, identifier)
} }
func auth_user(store *db.Store, username string, password string) (bool, error) { func auth_user(store *db.Store, username string, password string) (bool, error) {
@@ -395,7 +401,7 @@ func auth_user(store *db.Store, username string, password string) (bool, error)
return user.ID != "", nil return user.ID != "", nil
} }
func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger) (*Server, error) { func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identifier string) (*Server, error) {
var app Server var app Server
var dir string var dir string
var err error var err error
@@ -405,6 +411,7 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger) (*Serv
cfg.RPMHTTPPrefix = normalizeHTTPPrefix(cfg.RPMHTTPPrefix, "/rpm") cfg.RPMHTTPPrefix = normalizeHTTPPrefix(cfg.RPMHTTPPrefix, "/rpm")
app.cfg = cfg app.cfg = cfg
app.logger = logger app.logger = logger
app.serverIdentifier = NormalizeServerIdentifier(identifier)
app.docker_base_dir = filepath.Join(cfg.DataDir, "docker") app.docker_base_dir = filepath.Join(cfg.DataDir, "docker")
app.git_base_dir = filepath.Join(cfg.DataDir, "git") app.git_base_dir = filepath.Join(cfg.DataDir, "git")
@@ -441,13 +448,13 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger) (*Serv
goto oops goto oops
} }
err = mergeTLSSettingsFromDB(cfg, app.store) err = mergeTLSSettingsFromDB(cfg, app.store, EnvPrefixFromServerIdentifier(app.serverIdentifier))
if err != nil { if err != nil {
err = fmt.Errorf("tls settings load error: %v", err) err = fmt.Errorf("tls settings load error: %v", err)
goto oops goto oops
} }
err = bootstrapAdmin(app.store) err = bootstrapAdmin(app.store, EnvPrefixFromServerIdentifier(app.serverIdentifier))
if err != nil { if err != nil {
err = fmt.Errorf("bootstrap admin error: %v", err) err = fmt.Errorf("bootstrap admin error: %v", err)
goto oops goto oops
@@ -537,7 +544,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
cancel() cancel()
return err return err
} }
err = serveListeners(ctx, mainEndpoints, mux, app.logger) err = serveListeners(ctx, mainEndpoints, mux, app.logger, app.serverIdentifier)
shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
if extraListenerManager != nil { if extraListenerManager != nil {
extraListenerManager.Stop(shutdownCtx) extraListenerManager.Stop(shutdownCtx)
@@ -562,21 +569,18 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
var api *handlers.API var api *handlers.API
var router *codit_http.Router var router *codit_http.Router
// TODO: change api to reference app itself? api = handlers.NewAPI(handlers.APIOptions{
api = &handlers.API{ ServerIdentifier: app.serverIdentifier,
Cfg: *cfg, Cfg: *cfg,
Store: app.store, Store: app.store,
Repos: *app.git_manager, Repos: *app.git_manager,
RpmBase: app.rpm_base_dir, RpmBase: app.rpm_base_dir,
RpmMeta: app.rpm_meta, RpmMeta: app.rpm_meta,
RpmMirror: app.rpm_mirror, RpmMirror: app.rpm_mirror,
DockerBase: app.docker_base_dir, DockerBase: app.docker_base_dir,
Uploads: *app.upload_store, Uploads: *app.upload_store,
Logger: logger, Logger: logger,
SSHSessionRegistry: handlers.NewSSHSessionRegistry(), })
SSHPromptedAuthStore: handlers.NewSSHPromptedAuthStore(),
SSHPreparedSessionStore: handlers.NewSSHPreparedSessionStore(),
}
router = codit_http.NewRouter() router = codit_http.NewRouter()
@@ -588,6 +592,10 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/auth/oidc/callback", api.OIDCCallback) router.Handle("GET", "/api/auth/oidc/callback", api.OIDCCallback)
router.Handle("GET", "/api/me", api.Me) router.Handle("GET", "/api/me", api.Me)
router.Handle("PATCH", "/api/me", api.UpdateMe) router.Handle("PATCH", "/api/me", api.UpdateMe)
router.Handle("GET", "/api/me/totp", api.GetMyTOTP)
router.Handle("POST", "/api/me/totp/setup", api.SetupMyTOTP)
router.Handle("POST", "/api/me/totp/enable", api.EnableMyTOTP)
router.Handle("POST", "/api/me/totp/disable", api.DisableMyTOTP)
router.Handle("GET", "/api/users", api.ListUsers) router.Handle("GET", "/api/users", api.ListUsers)
router.Handle("POST", "/api/users", api.CreateUser) router.Handle("POST", "/api/users", api.CreateUser)
@@ -595,6 +603,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("DELETE", "/api/users/:id", api.DeleteUser) router.Handle("DELETE", "/api/users/:id", api.DeleteUser)
router.Handle("POST", "/api/users/:id/disable", api.DisableUser) router.Handle("POST", "/api/users/:id/disable", api.DisableUser)
router.Handle("POST", "/api/users/:id/enable", api.EnableUser) router.Handle("POST", "/api/users/:id/enable", api.EnableUser)
router.Handle("POST", "/api/users/:id/totp/reset", api.ResetUserTOTPAdmin)
router.Handle("GET", "/api/admin/user-groups", api.ListUserGroups) router.Handle("GET", "/api/admin/user-groups", api.ListUserGroups)
router.Handle("POST", "/api/admin/user-groups", api.CreateUserGroup) router.Handle("POST", "/api/admin/user-groups", api.CreateUserGroup)
router.Handle("PATCH", "/api/admin/user-groups/:id", api.UpdateUserGroup) router.Handle("PATCH", "/api/admin/user-groups/:id", api.UpdateUserGroup)
@@ -881,7 +890,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
// middleware.WithUser(store, // middleware.WithUser(store,
// withAPIAuth(router, auth_user, store, logger))))) // withAPIAuth(router, auth_user, store, logger)))))
apiHandler = withAPIAuth(router, auth_user, store, logger) apiHandler = withAPIAuth(router, auth_user, store, logger)
apiHandler = middleware.WithUser(store, apiHandler) apiHandler = middleware.WithUserCookie(store, sessionCookieNameFromServerIdentifier(app.serverIdentifier), apiHandler)
apiHandler = middleware.WithStoreTransaction(store, apiHandler) apiHandler = middleware.WithStoreTransaction(store, apiHandler)
apiHandler = middleware.AccessLog(logger, apiHandler) apiHandler = middleware.AccessLog(logger, apiHandler)
apiHandler = middleware.WithRequestStore(store, apiHandler) apiHandler = middleware.WithRequestStore(store, apiHandler)
@@ -893,7 +902,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
// middleware.AccessLog(logger, // middleware.AccessLog(logger,
// middleware.WithStoreTransaction(store, // middleware.WithStoreTransaction(store,
// middleware.WithUser(store, router)))) // middleware.WithUser(store, router))))
apiPublicHandler = middleware.WithUser(store, router) apiPublicHandler = middleware.WithUserCookie(store, sessionCookieNameFromServerIdentifier(app.serverIdentifier), router)
apiPublicHandler = middleware.WithStoreTransaction(store, apiPublicHandler) apiPublicHandler = middleware.WithStoreTransaction(store, apiPublicHandler)
apiPublicHandler = middleware.AccessLog(logger, apiPublicHandler) apiPublicHandler = middleware.AccessLog(logger, apiPublicHandler)
apiPublicHandler = middleware.WithRequestStore(store, apiPublicHandler) apiPublicHandler = middleware.WithRequestStore(store, apiPublicHandler)
@@ -904,7 +913,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
mux.Handle("/api/auth/oidc/login", apiPublicHandler) mux.Handle("/api/auth/oidc/login", apiPublicHandler)
mux.Handle("/api/auth/oidc/callback", apiPublicHandler) mux.Handle("/api/auth/oidc/callback", apiPublicHandler)
mux.Handle("/", middleware.WithUser(store, spaHandler(cfg.FrontendDir))) mux.Handle("/", middleware.WithUserCookie(store, sessionCookieNameFromServerIdentifier(app.serverIdentifier), spaHandler(cfg.FrontendDir)))
return api, mux, nil return api, mux, nil
} }
@@ -1932,7 +1941,60 @@ func normalizeHTTPPrefix(value string, fallback string) string {
return value return value
} }
func serveListeners(ctx context.Context, endpoints []listenerEndpoint, handler http.Handler, logger Logger) error { func sessionCookieNameFromServerIdentifier(value string) string {
return strings.ReplaceAll(NormalizeServerIdentifier(value), "-", "_") + "_session"
}
func oidcStateCookieNameFromServerIdentifier(value string) string {
return strings.ReplaceAll(NormalizeServerIdentifier(value), "-", "_") + "_oidc_state"
}
func NormalizeServerIdentifier(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, "_", "-")
value = strings.Trim(value, "-")
if value == "" {
return "codit"
}
return value
}
func EnvPrefixFromServerIdentifier(value string) string {
var prefix string
prefix = strings.ToUpper(strings.ReplaceAll(NormalizeServerIdentifier(value), "-", "_"))
if !strings.HasSuffix(prefix, "_") {
prefix = prefix + "_"
}
return prefix
}
func TitleFromServerIdentifier(value string) string {
var id string
var parts []string
var i int
id = NormalizeServerIdentifier(value)
parts = strings.Split(id, "-")
for i = 0; i < len(parts); i++ {
parts[i] = titleWord(parts[i])
}
return strings.Join(parts, " ")
}
func titleWord(value string) string {
var runes []rune
var i int
runes = []rune(strings.ToLower(strings.TrimSpace(value)))
if len(runes) == 0 {
return ""
}
runes[0] = unicode.ToTitle(runes[0])
for i = 1; i < len(runes); i++ {
runes[i] = unicode.ToLower(runes[i])
}
return string(runes)
}
func serveListeners(ctx context.Context, endpoints []listenerEndpoint, handler http.Handler, logger Logger, serverIdentifier string) error {
var listenCtx context.Context var listenCtx context.Context
var cancel context.CancelFunc var cancel context.CancelFunc
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -1943,6 +2005,7 @@ func serveListeners(ctx context.Context, endpoints []listenerEndpoint, handler h
var ep listenerEndpoint var ep listenerEndpoint
var listenErr error var listenErr error
var shutdown func() var shutdown func()
serverIdentifier = NormalizeServerIdentifier(serverIdentifier)
if len(endpoints) == 0 { if len(endpoints) == 0 {
return http.ErrServerClosed return http.ErrServerClosed
} }
@@ -1967,9 +2030,9 @@ func serveListeners(ctx context.Context, endpoints []listenerEndpoint, handler h
for i = 0; i < len(endpoints); i++ { for i = 0; i < len(endpoints); i++ {
ep = endpoints[i] ep = endpoints[i]
if ep.IsHTTPS { if ep.IsHTTPS {
logger.Write("", LOG_INFO, "codit server listener=%s https://%s", ep.Name, ep.Addr) logger.Write("", LOG_INFO, "%s server listener=%s https://%s", serverIdentifier, ep.Name, ep.Addr)
} else { } else {
logger.Write("", LOG_INFO, "codit server listener=%s %s", ep.Name, ep.Addr) logger.Write("", LOG_INFO, "%s server listener=%s %s", serverIdentifier, ep.Name, ep.Addr)
} }
wg.Add(1) wg.Add(1)
go func(endpoint listenerEndpoint) { go func(endpoint listenerEndpoint) {
@@ -2150,7 +2213,7 @@ func parseTLSMinVersion(value string) uint16 {
} }
} }
func mergeTLSSettingsFromDB(cfg *codit_config.Config, store *db.Store) error { func mergeTLSSettingsFromDB(cfg *codit_config.Config, store *db.Store, envPrefix string) error {
var settings models.TLSSettings var settings models.TLSSettings
var envHTTPAddrs string var envHTTPAddrs string
var envHTTPSAddrs string var envHTTPSAddrs string
@@ -2167,16 +2230,16 @@ func mergeTLSSettingsFromDB(cfg *codit_config.Config, store *db.Store) error {
settings, err = store.GetTLSSettings() settings, err = store.GetTLSSettings()
if err != nil { return err } if err != nil { return err }
envHTTPAddrs = strings.TrimSpace(os.Getenv("CODIT_HTTP_ADDRS")) envHTTPAddrs = strings.TrimSpace(os.Getenv(envPrefix + "HTTP_ADDRS"))
envHTTPSAddrs = strings.TrimSpace(os.Getenv("CODIT_HTTPS_ADDRS")) envHTTPSAddrs = strings.TrimSpace(os.Getenv(envPrefix + "HTTPS_ADDRS"))
envServerSource = strings.TrimSpace(os.Getenv("CODIT_TLS_SERVER_CERT_SOURCE")) envServerSource = strings.TrimSpace(os.Getenv(envPrefix + "TLS_SERVER_CERT_SOURCE"))
envCertFile = strings.TrimSpace(os.Getenv("CODIT_TLS_CERT_FILE")) envCertFile = strings.TrimSpace(os.Getenv(envPrefix + "TLS_CERT_FILE"))
envKeyFile = strings.TrimSpace(os.Getenv("CODIT_TLS_KEY_FILE")) envKeyFile = strings.TrimSpace(os.Getenv(envPrefix + "TLS_KEY_FILE"))
envPKIServerCertID = strings.TrimSpace(os.Getenv("CODIT_TLS_PKI_SERVER_CERT_ID")) envPKIServerCertID = strings.TrimSpace(os.Getenv(envPrefix + "TLS_PKI_SERVER_CERT_ID"))
envClientAuth = strings.TrimSpace(os.Getenv("CODIT_TLS_CLIENT_AUTH")) envClientAuth = strings.TrimSpace(os.Getenv(envPrefix + "TLS_CLIENT_AUTH"))
envClientCAFile = strings.TrimSpace(os.Getenv("CODIT_TLS_CLIENT_CA_FILE")) envClientCAFile = strings.TrimSpace(os.Getenv(envPrefix + "TLS_CLIENT_CA_FILE"))
envPKIClientCAID = strings.TrimSpace(os.Getenv("CODIT_TLS_PKI_CLIENT_CA_ID")) envPKIClientCAID = strings.TrimSpace(os.Getenv(envPrefix + "TLS_PKI_CLIENT_CA_ID"))
envTLSMinVersion = strings.TrimSpace(os.Getenv("CODIT_TLS_MIN_VERSION")) envTLSMinVersion = strings.TrimSpace(os.Getenv(envPrefix + "TLS_MIN_VERSION"))
if len(settings.HTTPAddrs) > 0 && envHTTPAddrs == "" { if len(settings.HTTPAddrs) > 0 && envHTTPAddrs == "" {
cfg.HTTPAddrs = settings.HTTPAddrs cfg.HTTPAddrs = settings.HTTPAddrs
} }
@@ -2247,7 +2310,7 @@ func spaHandler(root string) http.HandlerFunc {
} }
} }
func bootstrapAdmin(store *db.Store) error { func bootstrapAdmin(store *db.Store, envPrefix string) error {
var txStore *db.Store var txStore *db.Store
var bootstrap string var bootstrap string
var parts []string var parts []string
@@ -2256,7 +2319,7 @@ func bootstrapAdmin(store *db.Store) error {
var hash string var hash string
var err error var err error
bootstrap = os.Getenv("CODIT_BOOTSTRAP_ADMIN") bootstrap = os.Getenv(envPrefix + "BOOTSTRAP_ADMIN")
if bootstrap == "" { if bootstrap == "" {
return nil return nil
} }
+308
View File
@@ -0,0 +1,308 @@
package tests
import "database/sql"
import "path/filepath"
import "testing"
import "time"
import "codit/internal/auth"
import "codit/internal/db"
import "codit/internal/models"
import "codit/internal/util"
import _ "modernc.org/sqlite"
func openTestStore(t *testing.T) *db.Store {
var dir string
var dsn string
var store *db.Store
var err error
dir = t.TempDir()
dsn = "file:" + filepath.Join(dir, "test.db") + "?_pragma=foreign_keys(1)"
store, err = db.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
err = store.ApplyMigrations(filepath.Join("..", "migrations"))
if err != nil {
t.Fatalf("migrate db: %v", err)
}
return store
}
func createTestUser(t *testing.T, store *db.Store, username string) models.User {
var passwordHash string
var err error
var user models.User
passwordHash, err = auth.HashPassword("pass-123")
if err != nil {
t.Fatalf("hash password: %v", err)
}
user = models.User{
Username: username,
DisplayName: username,
Email: username + "@local",
IsAdmin: false,
Disabled: false,
AuthSource: "db",
}
user, err = store.CreateUser(user, passwordHash)
if err != nil {
t.Fatalf("create user: %v", err)
}
return user
}
func createAPIKey(t *testing.T, store *db.Store, userID string, name string, expiresAt int64) string {
var token string
var prefix string
var hash string
var key models.APIKey
var err error
token = "tok-" + name + "-" + time.Now().UTC().Format(time.RFC3339Nano)
if len(token) > 8 {
prefix = token[:8]
} else {
prefix = token
}
hash = util.HashToken(token)
key, err = store.CreateAPIKey(userID, name, hash, prefix, expiresAt)
if err != nil {
t.Fatalf("create api key: %v", err)
}
if key.ID == "" {
t.Fatalf("create api key: empty id")
}
return token
}
func createTestServicePrincipal(t *testing.T, store *db.Store, name string) models.ServicePrincipal {
var principal models.ServicePrincipal
var err error
principal = models.ServicePrincipal{
Name: name,
Description: name,
IsAdmin: false,
Disabled: false,
}
principal, err = store.CreateServicePrincipal(principal)
if err != nil {
t.Fatalf("create service principal: %v", err)
}
return principal
}
func createPrincipalAPIKey(t *testing.T, store *db.Store, principalID string, name string, expiresAt int64) string {
var token string
var prefix string
var hash string
var key models.PrincipalAPIKey
var err error
token = "ptok-" + name + "-" + time.Now().UTC().Format(time.RFC3339Nano)
if len(token) > 8 {
prefix = token[:8]
} else {
prefix = token
}
hash = util.HashToken(token)
key, err = store.CreatePrincipalAPIKey(principalID, name, hash, prefix, expiresAt)
if err != nil {
t.Fatalf("create principal api key: %v", err)
}
if key.ID == "" {
t.Fatalf("create principal api key: empty id")
}
return token
}
func TestAPIKeyAuthSuccess(t *testing.T) {
var store *db.Store
var user models.User
var token string
var got models.User
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "alice")
token = createAPIKey(t, store, user.ID, "default", 0)
got, err = store.GetUserByAPIKeyHash(util.HashToken(token))
if err != nil {
t.Fatalf("lookup by api key: %v", err)
}
if got.ID != user.ID {
t.Fatalf("unexpected user id: got=%s want=%s", got.ID, user.ID)
}
}
func TestAPIKeyAuthExpiredKeyFails(t *testing.T) {
var store *db.Store
var user models.User
var token string
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "bob")
token = createAPIKey(t, store, user.ID, "expired", time.Now().UTC().Unix()-60)
_, err = store.GetUserByAPIKeyHash(util.HashToken(token))
if err == nil {
t.Fatalf("expected error for expired key")
}
if err != sql.ErrNoRows {
t.Fatalf("unexpected error for expired key: %v", err)
}
}
func TestAPIKeyAuthDisabledKeyFails(t *testing.T) {
var store *db.Store
var user models.User
var token string
var keys []models.APIKey
var keyID string
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "carol")
token = createAPIKey(t, store, user.ID, "disabled", 0)
keys, err = store.ListAPIKeys(user.ID)
if err != nil {
t.Fatalf("list api keys: %v", err)
}
if len(keys) != 1 {
t.Fatalf("expected one key, got %d", len(keys))
}
keyID = keys[0].ID
err = store.SetAPIKeyDisabled(user.ID, keyID, true)
if err != nil {
t.Fatalf("disable key: %v", err)
}
_, err = store.GetUserByAPIKeyHash(util.HashToken(token))
if err == nil {
t.Fatalf("expected error for disabled key")
}
if err != sql.ErrNoRows {
t.Fatalf("unexpected error for disabled key: %v", err)
}
}
func TestAPIKeyAuthDisabledUserFails(t *testing.T) {
var store *db.Store
var user models.User
var token string
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "dave")
token = createAPIKey(t, store, user.ID, "user-disabled", 0)
err = store.SetUserDisabled(user.ID, true)
if err != nil {
t.Fatalf("disable user: %v", err)
}
_, err = store.GetUserByAPIKeyHash(util.HashToken(token))
if err == nil {
t.Fatalf("expected error for disabled user")
}
if err != sql.ErrNoRows {
t.Fatalf("unexpected error for disabled user: %v", err)
}
}
func TestDeleteUserCascadesAPIKeys(t *testing.T) {
var store *db.Store
var user models.User
var keys []models.APIKey
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "erin")
_ = createAPIKey(t, store, user.ID, "one", 0)
_ = createAPIKey(t, store, user.ID, "two", 0)
keys, err = store.ListAPIKeys(user.ID)
if err != nil {
t.Fatalf("list api keys before delete: %v", err)
}
if len(keys) != 2 {
t.Fatalf("expected two keys before delete, got %d", len(keys))
}
err = store.DeleteUser(user.ID)
if err != nil {
t.Fatalf("delete user: %v", err)
}
keys, err = store.ListAPIKeys(user.ID)
if err != nil {
t.Fatalf("list api keys after delete: %v", err)
}
if len(keys) != 0 {
t.Fatalf("expected zero keys after delete, got %d", len(keys))
}
}
func TestPrincipalAPIKeyAuthSuccess(t *testing.T) {
var store *db.Store
var principal models.ServicePrincipal
var token string
var got models.ServicePrincipal
var err error
store = openTestStore(t)
defer store.Close()
principal = createTestServicePrincipal(t, store, "robot")
token = createPrincipalAPIKey(t, store, principal.ID, "default", 0)
got, err = store.GetPrincipalByAPIKeyHash(util.HashToken(token))
if err != nil {
t.Fatalf("lookup principal by api key: %v", err)
}
if got.ID != principal.ID {
t.Fatalf("unexpected principal id: got=%s want=%s", got.ID, principal.ID)
}
}
func TestPrincipalAPIKeyAuthDisabledPrincipalFails(t *testing.T) {
var store *db.Store
var principal models.ServicePrincipal
var token string
var err error
store = openTestStore(t)
defer store.Close()
principal = createTestServicePrincipal(t, store, "robot-disabled")
token = createPrincipalAPIKey(t, store, principal.ID, "default", 0)
principal.Disabled = true
err = store.UpdateServicePrincipal(principal)
if err != nil {
t.Fatalf("disable principal: %v", err)
}
_, err = store.GetPrincipalByAPIKeyHash(util.HashToken(token))
if err == nil {
t.Fatalf("expected error for disabled principal")
}
if err != sql.ErrNoRows {
t.Fatalf("unexpected error for disabled principal: %v", err)
}
}
func TestListAPIKeysAdminIncludesPrincipalKeys(t *testing.T) {
var store *db.Store
var user models.User
var principal models.ServicePrincipal
var items []models.AdminAPIKey
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "frank")
principal = createTestServicePrincipal(t, store, "robot-admin-list")
_ = createAPIKey(t, store, user.ID, "user-key", 0)
_ = createPrincipalAPIKey(t, store, principal.ID, "principal-key", 0)
items, err = store.ListAPIKeysAdmin("", "")
if err != nil {
t.Fatalf("list admin api keys: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected two admin api keys, got %d", len(items))
}
if items[0].SubjectType == items[1].SubjectType {
t.Fatalf("expected mixed subject types, got %q and %q", items[0].SubjectType, items[1].SubjectType)
}
}
+208
View File
@@ -0,0 +1,208 @@
package tests
import "testing"
import "codit/internal/db"
import "codit/internal/models"
func createTestProject(t *testing.T, store *db.Store, user models.User, slug string) models.Project {
var project models.Project
var err error
t.Helper()
project = models.Project{
Slug: slug,
Name: slug,
Description: slug + " project",
CreatedBy: user.ID,
UpdatedBy: user.ID,
HomePage: "info",
}
project, err = store.CreateProject(project)
if err != nil {
t.Fatalf("create project: %v", err)
}
return project
}
func createTestRPMRepo(t *testing.T, store *db.Store, project models.Project, user models.User, name string) models.Repo {
var repo models.Repo
var err error
t.Helper()
repo = models.Repo{
ProjectID: project.ID,
Name: name,
Type: "rpm",
Path: "rpm/test/" + name,
CreatedBy: user.ID,
}
repo, err = store.CreateRepo(repo)
if err != nil {
t.Fatalf("create repo: %v", err)
}
return repo
}
func createTestRPMMirrorDir(t *testing.T, store *db.Store, repo models.Repo, path string, interval int64) models.RPMRepoDir {
var item models.RPMRepoDir
var err error
t.Helper()
item = models.RPMRepoDir{
RepoID: repo.ID,
Path: path,
Mode: "mirror",
RemoteURL: "https://example.invalid/repo",
SyncIntervalSec: interval,
SyncEnabled: true,
}
err = store.UpsertRPMRepoDir(item)
if err != nil {
t.Fatalf("upsert rpm mirror dir: %v", err)
}
item, err = store.GetRPMRepoDir(repo.ID, path)
if err != nil {
t.Fatalf("get rpm mirror dir: %v", err)
}
return item
}
func TestResetRunningRPMMirrorTasksMarksRunsFailed(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var repo models.Repo
var dir models.RPMRepoDir
var runID string
var started bool
var runs []models.RPMMirrorRun
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "mirror-reset")
project = createTestProject(t, store, user, "mirror-reset")
repo = createTestRPMRepo(t, store, project, user, "repo")
dir = createTestRPMMirrorDir(t, store, repo, "x86_64", 600)
runID, started, err = store.TryStartRPMMirrorRun(repo.ID, dir.Path, 1000)
if err != nil {
t.Fatalf("try start mirror run: %v", err)
}
if !started {
t.Fatalf("expected mirror run to start")
}
err = store.ResetRunningRPMMirrorTasks()
if err != nil {
t.Fatalf("reset running mirror tasks: %v", err)
}
dir, err = store.GetRPMRepoDir(repo.ID, dir.Path)
if err != nil {
t.Fatalf("get rpm repo dir after reset: %v", err)
}
if dir.SyncRunning {
t.Fatalf("expected sync_running=false after reset")
}
if dir.SyncStatus != "failed" {
t.Fatalf("unexpected sync status: got=%s want=failed", dir.SyncStatus)
}
if dir.SyncError != "aborted by restart" {
t.Fatalf("unexpected sync error: got=%q", dir.SyncError)
}
runs, err = store.ListRPMMirrorRuns(repo.ID, dir.Path, 10)
if err != nil {
t.Fatalf("list mirror runs after reset: %v", err)
}
if len(runs) != 1 {
t.Fatalf("expected one mirror run, got %d", len(runs))
}
if runs[0].ID != runID {
t.Fatalf("unexpected run id: got=%s want=%s", runs[0].ID, runID)
}
if runs[0].Status != "failed" {
t.Fatalf("unexpected run status: got=%s want=failed", runs[0].Status)
}
if runs[0].Error != "aborted by restart" {
t.Fatalf("unexpected run error: got=%q", runs[0].Error)
}
if runs[0].FinishedAt <= 0 {
t.Fatalf("expected finished_at to be set")
}
}
func TestFinishRPMMirrorTaskRunUpdatesTaskAndRun(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var repo models.Repo
var dir models.RPMRepoDir
var runID string
var started bool
var runs []models.RPMMirrorRun
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "mirror-finish")
project = createTestProject(t, store, user, "mirror-finish")
repo = createTestRPMRepo(t, store, project, user, "repo")
dir = createTestRPMMirrorDir(t, store, repo, "x86_64", 600)
runID, started, err = store.TryStartRPMMirrorRun(repo.ID, dir.Path, 1000)
if err != nil {
t.Fatalf("try start mirror run: %v", err)
}
if !started {
t.Fatalf("expected mirror run to start")
}
err = store.FinishRPMMirrorTaskRun(repo.ID, dir.Path, runID, true, 1700, "done", 4, 4, 0, 0, "rev-1", "")
if err != nil {
t.Fatalf("finish mirror task run: %v", err)
}
dir, err = store.GetRPMRepoDir(repo.ID, dir.Path)
if err != nil {
t.Fatalf("get rpm repo dir after finish: %v", err)
}
if dir.SyncRunning {
t.Fatalf("expected sync_running=false after finish")
}
if dir.Dirty {
t.Fatalf("expected dirty=false after success")
}
if dir.SyncStatus != "success" {
t.Fatalf("unexpected sync status: got=%s want=success", dir.SyncStatus)
}
if dir.LastSyncFinishedAt != 1700 {
t.Fatalf("unexpected last sync finished at: got=%d want=1700", dir.LastSyncFinishedAt)
}
if dir.LastSyncSuccessAt != 1700 {
t.Fatalf("unexpected last sync success at: got=%d want=1700", dir.LastSyncSuccessAt)
}
if dir.LastSyncedRevision != "rev-1" {
t.Fatalf("unexpected last synced revision: got=%s want=rev-1", dir.LastSyncedRevision)
}
if dir.NextSyncAt != 2300 {
t.Fatalf("unexpected next sync at: got=%d want=2300", dir.NextSyncAt)
}
runs, err = store.ListRPMMirrorRuns(repo.ID, dir.Path, 10)
if err != nil {
t.Fatalf("list mirror runs after finish: %v", err)
}
if len(runs) != 1 {
t.Fatalf("expected one mirror run, got %d", len(runs))
}
if runs[0].ID != runID {
t.Fatalf("unexpected run id: got=%s want=%s", runs[0].ID, runID)
}
if runs[0].Status != "success" {
t.Fatalf("unexpected run status: got=%s want=success", runs[0].Status)
}
if runs[0].Step != "done" {
t.Fatalf("unexpected run step: got=%s want=done", runs[0].Step)
}
if runs[0].Revision != "rev-1" {
t.Fatalf("unexpected run revision: got=%s want=rev-1", runs[0].Revision)
}
if runs[0].Done != 4 || runs[0].Total != 4 {
t.Fatalf("unexpected run counts: total=%d done=%d", runs[0].Total, runs[0].Done)
}
}
+105
View File
@@ -0,0 +1,105 @@
package tests
import "testing"
import "codit/internal/models"
func createTestUserGroup(t *testing.T, store interface {
CreateUserGroup(models.UserGroup) (models.UserGroup, error)
AddUserGroupMember(string, string) error
}, name string, userID string) models.UserGroup {
var group models.UserGroup
var err error
group = models.UserGroup{
Name: name,
Description: name,
Disabled: false,
}
group, err = store.CreateUserGroup(group)
if err != nil {
t.Fatalf("create user group: %v", err)
}
err = store.AddUserGroupMember(group.ID, userID)
if err != nil {
t.Fatalf("add user group member: %v", err)
}
return group
}
func TestSSHAccessProfileVisibilityForUserAndGroupTargets(t *testing.T) {
var store = openTestStore(t)
var user models.User
var other models.User
var group models.UserGroup
var server models.SSHServer
var created models.SSHAccessProfile
var visible []models.SSHAccessProfile
var err error
defer store.Close()
user = createTestUser(t, store, "ssh-broker-alice")
other = createTestUser(t, store, "ssh-broker-bob")
group = createTestUserGroup(t, store, "ssh-broker-ops", other.ID)
server, err = store.CreateSSHServer(models.SSHServer{
Name: "web-01",
Host: "10.0.0.11",
Port: 22,
Description: "web",
Tags: []string{"prod", "web"},
Enabled: true,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
})
if err != nil {
t.Fatalf("create ssh server: %v", err)
}
created, err = store.CreateSSHAccessProfile(models.SSHAccessProfile{
ServerID: server.ID,
Name: "web deploy",
Description: "deploy access",
RemoteUsername: "deploy",
AuthMethod: "stored_private_key",
OwnerScope: "admin_shared",
Enabled: true,
SecretPayload: "PRIVATE KEY",
AuthPublicKey: "ssh-ed25519 AAAA",
AuthPublicKeyFingerprint: "SHA256:test",
DefaultValidSeconds: 3600,
MaxValidSeconds: 3600,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
Targets: []models.SSHAccessProfileTarget{
{TargetType: "user", TargetID: user.ID},
{TargetType: "group", TargetID: group.ID},
},
})
if err != nil {
t.Fatalf("create ssh access profile: %v", err)
}
visible, err = store.ListSSHAccessProfilesForUser(user.ID)
if err != nil {
t.Fatalf("list visible ssh access profiles for user: %v", err)
}
if len(visible) != 1 {
t.Fatalf("unexpected direct visibility count: got=%d want=1", len(visible))
}
if visible[0].ID != created.ID {
t.Fatalf("unexpected direct visible profile id: got=%s want=%s", visible[0].ID, created.ID)
}
visible, err = store.ListSSHAccessProfilesForUser(other.ID)
if err != nil {
t.Fatalf("list visible ssh access profiles for group member: %v", err)
}
if len(visible) != 1 {
t.Fatalf("unexpected group visibility count: got=%d want=1", len(visible))
}
if visible[0].ID != created.ID {
t.Fatalf("unexpected group visible profile id: got=%s want=%s", visible[0].ID, created.ID)
}
if len(visible[0].Targets) != 2 {
t.Fatalf("unexpected target count: got=%d want=2", len(visible[0].Targets))
}
}
@@ -0,0 +1,51 @@
package tests
import "testing"
import "time"
import "codit/internal/models"
func TestSSHPrincipalGrantStoresMultiplePrincipals(t *testing.T) {
var dbStore = openTestStore(t)
var user models.User
var created models.SSHPrincipalGrant
var loaded models.SSHPrincipalGrant
var active []models.SSHPrincipalGrant
var err error
defer dbStore.Close()
user = createTestUser(t, dbStore, "ssh-grant-user")
created, err = dbStore.CreateSSHPrincipalGrant(models.SSHPrincipalGrant{
Name: "hyung-hwan-login",
Principals: []string{"hyung-hwan", "ops"},
Targets: []models.SSHPrincipalGrantTarget{
{TargetType: "user", TargetID: user.ID},
},
})
if err != nil {
t.Fatalf("create ssh principal grant: %v", err)
}
loaded, err = dbStore.GetSSHPrincipalGrant(created.ID)
if err != nil {
t.Fatalf("get ssh principal grant: %v", err)
}
if loaded.Name != "hyung-hwan-login" {
t.Fatalf("unexpected grant name: got=%s want=hyung-hwan-login", loaded.Name)
}
if len(loaded.Principals) != 2 {
t.Fatalf("unexpected principal count: got=%d want=2", len(loaded.Principals))
}
if loaded.Principals[0] != "hyung-hwan" || loaded.Principals[1] != "ops" {
t.Fatalf("unexpected principals: got=%v", loaded.Principals)
}
active, err = dbStore.ListActiveSSHPrincipalGrantsForUser(user.ID, time.Now().UTC().Unix())
if err != nil {
t.Fatalf("list active ssh principal grants: %v", err)
}
if len(active) != 1 {
t.Fatalf("unexpected active grant count: got=%d want=1", len(active))
}
if len(active[0].Principals) != 2 {
t.Fatalf("unexpected active grant principal count: got=%d want=2", len(active[0].Principals))
}
}
+2
View File
@@ -0,0 +1,2 @@
VITE_APP_IDENTIFIER=codit
VITE_APP_TITLE=Codit
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codit</title> <title>%VITE_APP_TITLE%</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+10
View File
@@ -16,6 +16,7 @@
"@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-unicode11": "^0.9.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
@@ -3433,6 +3434,15 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-unicode11": "^0.9.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
+19 -2
View File
@@ -5,10 +5,20 @@ export interface User {
email: string email: string
is_admin: boolean is_admin: boolean
auth_source?: string auth_source?: string
totp_enabled?: boolean
disabled?: boolean disabled?: boolean
permissions?: string[] permissions?: string[]
} }
export type TOTPStatus = {
enabled: boolean
}
export type TOTPSetupResponse = {
secret: string
otpauth_url: string
}
export type UserCreatePayload = { export type UserCreatePayload = {
username: string username: string
display_name: string display_name: string
@@ -1117,15 +1127,21 @@ async function requestText(path: string, options: RequestInit = {}): Promise<str
export const api = { export const api = {
oidcStatus: () => request<OIDCStatus>('/api/auth/oidc/enabled'), oidcStatus: () => request<OIDCStatus>('/api/auth/oidc/enabled'),
login: (username: string, password: string) => login: (username: string, password: string, otpCode?: string) =>
request<User>('/api/login', { request<User>('/api/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password, otp_code: otpCode || '' })
}), }),
logout: () => request<void>('/api/logout', { method: 'POST' }), logout: () => request<void>('/api/logout', { method: 'POST' }),
me: () => request<User>('/api/me'), me: () => request<User>('/api/me'),
updateMe: (payload: MeUpdatePayload) => updateMe: (payload: MeUpdatePayload) =>
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }), request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
getMyTOTP: () => request<TOTPStatus>('/api/me/totp'),
setupMyTOTP: () => request<TOTPSetupResponse>('/api/me/totp/setup', { method: 'POST' }),
enableMyTOTP: (otpCode: string) =>
request<TOTPStatus>('/api/me/totp/enable', { method: 'POST', body: JSON.stringify({ otp_code: otpCode }) }),
disableMyTOTP: (password: string, otpCode: string) =>
request<TOTPStatus>('/api/me/totp/disable', { method: 'POST', body: JSON.stringify({ password, otp_code: otpCode }) }),
listAPIKeys: () => request<APIKey[]>('/api/me/keys'), listAPIKeys: () => request<APIKey[]>('/api/me/keys'),
createAPIKey: (name: string, expires_at?: number) => createAPIKey: (name: string, expires_at?: number) =>
request<APIKeyWithToken>('/api/me/keys', { method: 'POST', body: JSON.stringify({ name, expires_at: expires_at || 0 }) }), request<APIKeyWithToken>('/api/me/keys', { method: 'POST', body: JSON.stringify({ name, expires_at: expires_at || 0 }) }),
@@ -1396,6 +1412,7 @@ export const api = {
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }), deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
disableUser: (id: string) => request<void>(`/api/users/${id}/disable`, { method: 'POST' }), disableUser: (id: string) => request<void>(`/api/users/${id}/disable`, { method: 'POST' }),
enableUser: (id: string) => request<void>(`/api/users/${id}/enable`, { method: 'POST' }), enableUser: (id: string) => request<void>(`/api/users/${id}/enable`, { method: 'POST' }),
resetUserTOTP: (id: string) => request<void>(`/api/users/${id}/totp/reset`, { method: 'POST' }),
listUserGroups: () => request<UserGroup[]>('/api/admin/user-groups'), listUserGroups: () => request<UserGroup[]>('/api/admin/user-groups'),
createUserGroup: (payload: UserGroupUpsertPayload) => createUserGroup: (payload: UserGroupUpsertPayload) =>
request<UserGroup>('/api/admin/user-groups', { method: 'POST', body: JSON.stringify(payload) }), request<UserGroup>('/api/admin/user-groups', { method: 'POST', body: JSON.stringify(payload) }),
+5 -5
View File
@@ -5,15 +5,16 @@ import { useRoutes } from 'react-router-dom'
import { routes } from './routes' import { routes } from './routes'
import { ThemeModeContext } from './ThemeModeContext' import { ThemeModeContext } from './ThemeModeContext'
import { createAppTheme, isThemeScheme, ThemeScheme } from './theme' import { createAppTheme, isThemeScheme, ThemeScheme } from './theme'
import { storageKey } from '../branding'
export default function App() { export default function App() {
const element = useRoutes(routes) const element = useRoutes(routes)
const [mode, setMode] = useState<'light' | 'dark'>(() => { const [mode, setMode] = useState<'light' | 'dark'>(() => {
const stored = localStorage.getItem('codit-theme-mode') || localStorage.getItem('codit-theme') const stored = localStorage.getItem(storageKey('theme-mode'))
return stored === 'dark' ? 'dark' : 'light' return stored === 'dark' ? 'dark' : 'light'
}) })
const [scheme, setScheme] = useState<ThemeScheme>(() => { const [scheme, setScheme] = useState<ThemeScheme>(() => {
const stored = localStorage.getItem('codit-theme-scheme') const stored = localStorage.getItem(storageKey('theme-scheme'))
return isThemeScheme(stored) ? stored : 'blue' return isThemeScheme(stored) ? stored : 'blue'
}) })
@@ -44,8 +45,7 @@ export default function App() {
}, [mode]) }, [mode])
const updateMode = (next: 'light' | 'dark') => { const updateMode = (next: 'light' | 'dark') => {
localStorage.setItem('codit-theme-mode', next) localStorage.setItem(storageKey('theme-mode'), next)
localStorage.setItem('codit-theme', next)
setMode(next) setMode(next)
} }
@@ -54,7 +54,7 @@ export default function App() {
} }
const updateScheme = (next: ThemeScheme) => { const updateScheme = (next: ThemeScheme) => {
localStorage.setItem('codit-theme-scheme', next) localStorage.setItem(storageKey('theme-scheme'), next)
setScheme(next) setScheme(next)
} }
+2 -1
View File
@@ -18,6 +18,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom' import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { api, User } from '../api' import { api, User } from '../api'
import { appBrand } from '../branding'
import MenuIcon from '@mui/icons-material/Menu' import MenuIcon from '@mui/icons-material/Menu'
import DashboardIcon from '@mui/icons-material/Dashboard' import DashboardIcon from '@mui/icons-material/Dashboard'
import WorkspacesIcon from '@mui/icons-material/Workspaces' import WorkspacesIcon from '@mui/icons-material/Workspaces'
@@ -161,7 +162,7 @@ export default function Layout() {
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Typography variant="h6" sx={{ flexGrow: 1 }}> <Typography variant="h6" sx={{ flexGrow: 1 }}>
Codit {appBrand.title}
</Typography> </Typography>
<Button color="inherit" onClick={openAppearanceMenu} startIcon={<PaletteIcon />} endIcon={<KeyboardArrowDownIcon />}> <Button color="inherit" onClick={openAppearanceMenu} startIcon={<PaletteIcon />} endIcon={<KeyboardArrowDownIcon />}>
Appearance Appearance
+10
View File
@@ -0,0 +1,10 @@
export const appBrand = {
identifier: import.meta.env.VITE_APP_IDENTIFIER || 'codit',
title: import.meta.env.VITE_APP_TITLE || 'Codit',
storagePrefix: import.meta.env.VITE_APP_IDENTIFIER || 'codit',
sanURIPrefix: `urn:${import.meta.env.VITE_APP_IDENTIFIER || 'codit'}:user:`
}
export function storageKey(name: string): string {
return `${appBrand.storagePrefix}-${name}`
}
@@ -11,6 +11,7 @@ import TextField from '@mui/material/TextField'
import Autocomplete from './Autocomplete' import Autocomplete from './Autocomplete'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import SelectField from './SelectField' import SelectField from './SelectField'
import { appBrand } from '../branding'
import { PKICA } from '../api' import { PKICA } from '../api'
type PKIClientProfileFormOption = { type PKIClientProfileFormOption = {
@@ -69,7 +70,7 @@ export default function PKIClientProfileFormDialog(props: PKIClientProfileFormDi
label="SAN URI Prefix" label="SAN URI Prefix"
value={props.form.sanURIPrefix} value={props.form.sanURIPrefix}
onChange={(event) => props.setForm((prev) => ({ ...prev, sanURIPrefix: event.target.value }))} onChange={(event) => props.setForm((prev) => ({ ...prev, sanURIPrefix: event.target.value }))}
helperText="The user id is appended to this prefix. Example: urn:codit:user:" helperText={`The user id is appended to this prefix. Example: ${appBrand.sanURIPrefix}`}
/> />
<FormControlLabel <FormControlLabel
control={<Checkbox checked={props.form.allowServerAuth} onChange={(event) => props.setForm((prev) => ({ ...prev, allowServerAuth: event.target.checked }))} />} control={<Checkbox checked={props.form.allowServerAuth} onChange={(event) => props.setForm((prev) => ({ ...prev, allowServerAuth: event.target.checked }))} />}
+124 -1
View File
@@ -1,7 +1,8 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import { Box, Button, Paper, TextField, Typography } from '@mui/material' import { Box, Button, Paper, TextField, Typography } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { api, User } from '../api' import { QRCodeSVG } from 'qrcode.react'
import { api, TOTPSetupResponse, User } from '../api'
export default function AccountPage() { export default function AccountPage() {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
@@ -12,6 +13,12 @@ export default function AccountPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [totpEnabled, setTOTPEnabled] = useState(false)
const [totpSetup, setTOTPSetup] = useState<TOTPSetupResponse | null>(null)
const [totpCode, setTOTPCode] = useState('')
const [totpPassword, setTOTPPassword] = useState('')
const [totpBusy, setTOTPBusy] = useState(false)
const [totpError, setTOTPError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
api api
@@ -20,10 +27,19 @@ export default function AccountPage() {
setUser(me) setUser(me)
setDisplayName(me.display_name || '') setDisplayName(me.display_name || '')
setEmail(me.email || '') setEmail(me.email || '')
setTOTPEnabled(Boolean(me.totp_enabled))
}) })
.catch(() => { .catch(() => {
setUser(null) setUser(null)
}) })
api
.getMyTOTP()
.then((status) => {
setTOTPEnabled(Boolean(status.enabled))
})
.catch(() => {
setTOTPEnabled(false)
})
}, []) }, [])
const handleSave = async () => { const handleSave = async () => {
@@ -54,6 +70,58 @@ export default function AccountPage() {
} }
} }
const handleSetupTOTP = async () => {
let setup: TOTPSetupResponse
let message: string
setTOTPBusy(true)
setTOTPError(null)
try {
setup = await api.setupMyTOTP()
setTOTPSetup(setup)
setTOTPCode('')
} catch (err) {
message = err instanceof Error ? err.message : 'Failed to start TOTP setup'
setTOTPError(message)
} finally {
setTOTPBusy(false)
}
}
const handleEnableTOTP = async () => {
let message: string
setTOTPBusy(true)
setTOTPError(null)
try {
await api.enableMyTOTP(totpCode.trim())
setTOTPEnabled(true)
setTOTPSetup(null)
setTOTPCode('')
} catch (err) {
message = err instanceof Error ? err.message : 'Failed to enable TOTP'
setTOTPError(message)
} finally {
setTOTPBusy(false)
}
}
const handleDisableTOTP = async () => {
let message: string
setTOTPBusy(true)
setTOTPError(null)
try {
await api.disableMyTOTP(totpPassword, totpCode.trim())
setTOTPEnabled(false)
setTOTPSetup(null)
setTOTPPassword('')
setTOTPCode('')
} catch (err) {
message = err instanceof Error ? err.message : 'Failed to disable TOTP'
setTOTPError(message)
} finally {
setTOTPBusy(false)
}
}
return ( return (
<Box> <Box>
<Typography variant="h5" sx={{ mb: 2 }}> <Typography variant="h5" sx={{ mb: 2 }}>
@@ -96,6 +164,61 @@ export default function AccountPage() {
</Box> </Box>
</Box> </Box>
</Paper> </Paper>
<Paper sx={{ p: 2, maxWidth: 560, mt: 2, backgroundColor: 'transparent' }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Authenticator App
</Typography>
{totpError ? <Alert severity="error" sx={{ mb: 1 }}>{totpError}</Alert> : null}
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Status: {totpEnabled ? 'Enabled' : 'Disabled'}
</Typography>
{!totpEnabled ? (
<Box sx={{ display: 'grid', gap: 1 }}>
<Button variant="outlined" onClick={handleSetupTOTP} disabled={totpBusy}>
Start TOTP Setup
</Button>
{totpSetup ? (
<>
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2, backgroundColor: '#fff', borderRadius: 1 }}>
<QRCodeSVG value={totpSetup.otpauth_url} size={192} />
</Box>
<TextField label="Secret" value={totpSetup.secret} InputProps={{ readOnly: true }} />
<TextField label="otpauth URL" value={totpSetup.otpauth_url} InputProps={{ readOnly: true }} multiline />
<TextField
label="Authenticator Code"
value={totpCode}
onChange={(event) => setTOTPCode(event.target.value)}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" onClick={handleEnableTOTP} disabled={totpBusy || totpCode.trim() === ''}>
Enable TOTP
</Button>
</Box>
</>
) : null}
</Box>
) : (
<Box sx={{ display: 'grid', gap: 1 }}>
<TextField
label="Current Password"
type="password"
value={totpPassword}
onChange={(event) => setTOTPPassword(event.target.value)}
helperText={user?.auth_source === 'db' ? 'Required for db users.' : 'Non-db users can leave this empty.'}
/>
<TextField
label="Authenticator Code"
value={totpCode}
onChange={(event) => setTOTPCode(event.target.value)}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button color="warning" variant="outlined" onClick={handleDisableTOTP} disabled={totpBusy || totpCode.trim() === ''}>
Disable TOTP
</Button>
</Box>
</Box>
)}
</Paper>
</Box> </Box>
) )
} }
@@ -19,6 +19,7 @@ import PKIClientProfileDetailsDialog from '../components/PKIClientProfileDetails
import PKICADetailsDialog from '../components/PKICADetailsDialog' import PKICADetailsDialog from '../components/PKICADetailsDialog'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api' import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api'
import { appBrand } from '../branding'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
@@ -50,8 +51,8 @@ function defaultFormState(defaultCAID: string): PKIClientProfileFormState {
return { return {
name: '', name: '',
caID: defaultCAID, caID: defaultCAID,
subjectOrganization: 'Codit', subjectOrganization: appBrand.title,
sanURIPrefix: 'urn:codit:user:', sanURIPrefix: appBrand.sanURIPrefix,
allowServerAuth: false, allowServerAuth: false,
permissionsText: 'read', permissionsText: 'read',
authzScope: '', authzScope: '',
@@ -139,8 +140,8 @@ export default function AdminPKIClientProfilesPage() {
setForm({ setForm({
name: item.name, name: item.name,
caID: item.ca_id, caID: item.ca_id,
subjectOrganization: item.subject_organization || 'Codit', subjectOrganization: item.subject_organization || appBrand.title,
sanURIPrefix: item.san_uri_prefix || 'urn:codit:user:', sanURIPrefix: item.san_uri_prefix || appBrand.sanURIPrefix,
allowServerAuth: Boolean(item.allow_server_auth), allowServerAuth: Boolean(item.allow_server_auth),
permissionsText: (item.authz_permissions || []).join(', '), permissionsText: (item.authz_permissions || []).join(', '),
authzScope: item.authz_scope || '', authzScope: item.authz_scope || '',
+44 -8
View File
@@ -1,17 +1,10 @@
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import FormDialogContent from '../components/FormDialogContent'
import { import {
Box, Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -52,6 +45,9 @@ export default function AdminUsersPage() {
const [editForm, setEditForm] = useState<UserFormState>(createEmptyForm) const [editForm, setEditForm] = useState<UserFormState>(createEmptyForm)
const [editError, setEditError] = useState<string | null>(null) const [editError, setEditError] = useState<string | null>(null)
const [savingEdit, setSavingEdit] = useState(false) const [savingEdit, setSavingEdit] = useState(false)
const [resetTOTPUser, setResetTOTPUser] = useState<User | null>(null)
const [resetTOTPConfirm, setResetTOTPConfirm] = useState('')
const [resettingTOTP, setResettingTOTP] = useState(false)
useEffect(() => { useEffect(() => {
reloadUsers().catch((err) => { reloadUsers().catch((err) => {
@@ -164,6 +160,26 @@ export default function AdminUsersPage() {
} }
} }
const handleResetTOTP = async () => {
let message: string
if (!resetTOTPUser) {
return
}
setResettingTOTP(true)
setError(null)
try {
await api.resetUserTOTP(resetTOTPUser.id)
setResetTOTPUser(null)
setResetTOTPConfirm('')
await reloadUsers()
} catch (err) {
message = err instanceof Error ? err.message : 'Failed to reset TOTP'
setError(message)
} finally {
setResettingTOTP(false)
}
}
const openEditUser = (user: User) => { const openEditUser = (user: User) => {
setEditUser(user) setEditUser(user)
setEditForm({ setEditForm({
@@ -249,7 +265,7 @@ export default function AdminUsersPage() {
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText <ListItemText
primary={`${u.display_name || u.username} (${u.username})${u.disabled ? ' (disabled)' : ''}`} primary={`${u.display_name || u.username} (${u.username})${u.disabled ? ' (disabled)' : ''}`}
secondary={`${u.is_admin ? 'admin' : 'user'} · source: ${u.auth_source || 'db'}${hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}`} secondary={`${u.is_admin ? 'admin' : 'user'} · source: ${u.auth_source || 'db'}${u.totp_enabled ? ' · TOTP enabled' : ''}${hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}`}
sx={{ minWidth: 0, m: 0 }} sx={{ minWidth: 0, m: 0 }}
/> />
<ListRowActions> <ListRowActions>
@@ -263,6 +279,13 @@ export default function AdminUsersPage() {
> >
{u.disabled ? 'Enable' : 'Disable'} {u.disabled ? 'Enable' : 'Disable'}
</ListRowActionButton> </ListRowActionButton>
<ListRowActionButton
color="warning"
onClick={() => setResetTOTPUser(u)}
disabled={!u.totp_enabled}
>
Reset TOTP
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => setDeletingUser(u)}> <ListRowActionButton color="error" onClick={() => setDeletingUser(u)}>
Delete Delete
</ListRowActionButton> </ListRowActionButton>
@@ -305,6 +328,19 @@ export default function AdminUsersPage() {
onConfirm={handleDeleteUser} onConfirm={handleDeleteUser}
onClose={() => { setDeletingUser(null); setDeleteConfirm('') }} onClose={() => { setDeletingUser(null); setDeleteConfirm('') }}
/> />
<ConfirmDeleteDialog
open={Boolean(resetTOTPUser)}
title="Reset TOTP"
prompt="Type the username to confirm resetting this user's TOTP."
expectedText={resetTOTPUser?.username}
confirmLabel="Username"
confirmValue={resetTOTPConfirm}
onConfirmValueChange={setResetTOTPConfirm}
busy={resettingTOTP}
confirmDisabled={!resetTOTPUser}
onConfirm={handleResetTOTP}
onClose={() => { setResetTOTPUser(null); setResetTOTPConfirm('') }}
/>
</Box> </Box>
) )
} }
+31 -9
View File
@@ -2,11 +2,16 @@ import { Box, Button, Paper, TextField, Typography } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { appBrand } from '../branding'
export default function LoginPage() { export default function LoginPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [oidcEnabled, setOIDCEnabled] = useState(false) const [oidcEnabled, setOIDCEnabled] = useState(false)
const [oidcConfigured, setOIDCConfigured] = useState(true) const [oidcConfigured, setOIDCConfigured] = useState(true)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [otpCode, setOTPCode] = useState('')
const [otpRequired, setOTPRequired] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
@@ -23,33 +28,50 @@ export default function LoginPage() {
}, []) }, [])
const handleLogin = async (evt: React.FormEvent<HTMLFormElement>) => { const handleLogin = async (evt: React.FormEvent<HTMLFormElement>) => {
let message: string
evt.preventDefault() evt.preventDefault()
const data = new FormData(evt.currentTarget) setError(null)
const username = String(data.get('username') || '')
const password = String(data.get('password') || '')
try { try {
await api.login(username, password) await api.login(username, password, otpRequired ? otpCode : '')
navigate('/') navigate('/')
} catch (err) { } catch (err) {
setError((err as Error).message) message = err instanceof Error ? err.message : 'Login failed'
if (message === 'totp_required') {
setOTPRequired(true)
setOTPCode('')
setError('Enter your authenticator code.')
return
}
setError(message)
} }
} }
return ( return (
<Paper sx={{ p: 4, maxWidth: 420, mx: 'auto', mt: 12, backgroundColor: 'transparent' }}> <Paper sx={{ p: 4, maxWidth: 420, mx: 'auto', mt: 12, backgroundColor: 'transparent' }}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Sign in to Codit Sign in to {appBrand.title}
</Typography> </Typography>
<Box component="form" onSubmit={handleLogin}> <Box component="form" onSubmit={handleLogin}>
<TextField name="username" label="Username" fullWidth margin="normal" /> <TextField name="username" label="Username" fullWidth margin="normal" value={username} onChange={(event) => setUsername(event.target.value)} />
<TextField name="password" label="Password" type="password" fullWidth margin="normal" /> <TextField name="password" label="Password" type="password" fullWidth margin="normal" value={password} onChange={(event) => setPassword(event.target.value)} />
{otpRequired ? (
<TextField
name="otp_code"
label="Authenticator Code"
fullWidth
margin="normal"
value={otpCode}
onChange={(event) => setOTPCode(event.target.value)}
autoFocus
/>
) : null}
{error && ( {error && (
<Typography color="error" variant="body2"> <Typography color="error" variant="body2">
{error} {error}
</Typography> </Typography>
)} )}
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}> <Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
Login {otpRequired ? 'Verify' : 'Login'}
</Button> </Button>
{oidcEnabled ? ( {oidcEnabled ? (
<> <>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />