Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efe82944ab | |||
| 8274a623e4 |
@@ -16,6 +16,9 @@ codit-server.debug:
|
||||
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)
|
||||
|
||||
check:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
go clean -x
|
||||
rm -rf codit-server codit-server.debug codit-data-browser
|
||||
|
||||
@@ -55,20 +55,20 @@ func run() int {
|
||||
flag.Var(&configPaths, "config-file-pattern", "wildcard pattern for YAML configuration files; repeatable")
|
||||
flag.Parse()
|
||||
|
||||
logger = codit.NewAsyncLogger("codit", os.Stderr, codit.LOG_ALL)
|
||||
logger = codit.NewAsyncLogger(PROGRAM_NAME, os.Stderr, codit.LOG_ALL)
|
||||
defer logger.Close()
|
||||
ctx, stop = signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
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)
|
||||
|
||||
cfg, err = codit.LoadConfigFilesWithEnvPrefix(configPaths, "CODIT_")
|
||||
cfg, err = codit.LoadConfigFilesWithEnvPrefix(configPaths, codit.EnvPrefixFromServerIdentifier(PROGRAM_NAME))
|
||||
if err != nil {
|
||||
logger.Write("", codit.LOG_ERROR, "load config: %v", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
server, err = codit.NewServer(&cfg, logger)
|
||||
server, err = codit.NewServerWithIdentifier(&cfg, logger, PROGRAM_NAME)
|
||||
if err != nil {
|
||||
logger.Write("", codit.LOG_ERROR, "init app: %v", err)
|
||||
return 1
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -84,8 +84,8 @@ func (s *Store) GetUserByID(id string) (models.User, error) {
|
||||
var created time.Time
|
||||
var updated time.Time
|
||||
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)
|
||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &created, &updated)
|
||||
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, &user.TOTPEnabled, &created, &updated)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
@@ -101,8 +101,8 @@ func (s *Store) GetUserByUsername(username string) (models.User, string, error)
|
||||
var err error
|
||||
var created 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)
|
||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &created, &updated)
|
||||
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, &user.TOTPEnabled, &created, &updated)
|
||||
if err != nil {
|
||||
return user, passwordHash.String, err
|
||||
}
|
||||
@@ -118,13 +118,13 @@ func (s *Store) ListUsers() ([]models.User, error) {
|
||||
var u models.User
|
||||
var created 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 {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -874,11 +874,12 @@ func (s *Store) GetSessionUser(token string) (models.User, time.Time, error) {
|
||||
var created time.Time
|
||||
var updated time.Time
|
||||
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
|
||||
LEFT JOIN user_totp t ON t.user_id = u.id
|
||||
WHERE s.token = ? AND u.disabled = 0
|
||||
`, 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 {
|
||||
return user, time.Time{}, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -715,7 +715,7 @@ func (api *API) ensureACMEAccount(ctx context.Context, profile models.ACMEProfil
|
||||
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) != "" {
|
||||
client.KID = acme.KeyID(strings.TrimSpace(profile.AccountURL))
|
||||
return client, key, profile, nil
|
||||
|
||||
@@ -30,7 +30,26 @@ import "codit/internal/storage"
|
||||
import "codit/internal/util"
|
||||
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 {
|
||||
ServerIdentifier string
|
||||
ServerTitle string
|
||||
SessionCookieName string
|
||||
OIDCStateCookieName string
|
||||
Cfg config.Config
|
||||
Store *db.Store
|
||||
Repos git.RepoManager
|
||||
@@ -47,11 +66,70 @@ type API struct {
|
||||
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"
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
OTPCode string `json:"otp_code"`
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
@@ -380,8 +458,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
|
||||
return
|
||||
}
|
||||
if err == nil && storedHash != "" && auth.ComparePassword(storedHash, req.Password) == nil {
|
||||
api.issueSession(w, r, user)
|
||||
WriteJSON(w, http.StatusOK, user)
|
||||
api.finishLogin(w, r, user, req.OTPCode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -410,8 +487,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
|
||||
user = created
|
||||
}
|
||||
}
|
||||
api.issueSession(w, r, user)
|
||||
WriteJSON(w, http.StatusOK, user)
|
||||
api.finishLogin(w, r, user, req.OTPCode)
|
||||
return
|
||||
}
|
||||
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).CreateSession(user.ID, token, expires)
|
||||
cookie = &http.Cookie{
|
||||
Name: "codit_session",
|
||||
Name: api.sessionCookieName(),
|
||||
Value: token,
|
||||
HttpOnly: true,
|
||||
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) {
|
||||
var cookie *http.Cookie
|
||||
var err error
|
||||
cookie, err = r.Cookie("codit_session")
|
||||
cookie, err = r.Cookie(api.sessionCookieName())
|
||||
if err == nil {
|
||||
_ = api.store(r).DeleteSession(cookie.Value)
|
||||
}
|
||||
cookie = &http.Cookie{
|
||||
Name: "codit_session",
|
||||
Name: api.sessionCookieName(),
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
|
||||
@@ -83,7 +83,7 @@ func (api *API) OIDCLogin(w http.ResponseWriter, r *http.Request, _ map[string]s
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "codit_oidc_state",
|
||||
Name: api.oidcStateCookieName(),
|
||||
Value: state,
|
||||
HttpOnly: true,
|
||||
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"))
|
||||
code = strings.TrimSpace(r.URL.Query().Get("code"))
|
||||
cookie, err = r.Cookie("codit_oidc_state")
|
||||
clearOIDCStateCookie(w)
|
||||
cookie, err = r.Cookie(api.oidcStateCookieName())
|
||||
api.clearOIDCStateCookie(w)
|
||||
if err != nil || cookie.Value == "" || state == "" || state != cookie.Value {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid oidc state"})
|
||||
return
|
||||
@@ -411,9 +411,9 @@ func newOIDCState() (string, error) {
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func clearOIDCStateCookie(w http.ResponseWriter) {
|
||||
func (api *API) clearOIDCStateCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "codit_oidc_state",
|
||||
Name: api.oidcStateCookieName(),
|
||||
Value: "",
|
||||
Path: "/api/auth/oidc",
|
||||
Expires: time.Unix(0, 0),
|
||||
|
||||
@@ -337,7 +337,7 @@ func (api *API) CreatePKIRootCA(w http.ResponseWriter, r *http.Request, _ map[st
|
||||
keyPEM = req.KeyPEM
|
||||
_ = key
|
||||
} 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 {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -1209,7 +1209,7 @@ func parseTLSCertificatePair(certPEM string, keyPEM string) (*x509.Certificate,
|
||||
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 now time.Time
|
||||
var end time.Time
|
||||
@@ -1219,6 +1219,9 @@ func generateRootCA(commonName string, days int) (string, string, error) {
|
||||
var certPEM string
|
||||
var keyPEM string
|
||||
var err error
|
||||
if strings.TrimSpace(commonName) == "" {
|
||||
commonName = strings.TrimSpace(defaultCommonName)
|
||||
}
|
||||
if strings.TrimSpace(commonName) == "" {
|
||||
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()})
|
||||
return
|
||||
}
|
||||
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization)
|
||||
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix)
|
||||
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization, api.serverTitle())
|
||||
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix, api.serverIdentifier())
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
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()})
|
||||
return
|
||||
}
|
||||
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization)
|
||||
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix)
|
||||
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization, api.serverTitle())
|
||||
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix, api.serverIdentifier())
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -792,23 +792,25 @@ func normalizePKIClientProfileTargets(raw []pkiClientProfileTargetRequest) ([]mo
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizePKIClientSubjectOrganization(raw string) string {
|
||||
func normalizePKIClientSubjectOrganization(raw string, defaultValue string) string {
|
||||
var value string
|
||||
value = strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "Codit"
|
||||
}
|
||||
if value == "" { return defaultValue }
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizePKIClientSANURIPrefix(raw string) (string, error) {
|
||||
func normalizePKIClientSANURIPrefix(raw string, serverIdentifier string) (string, error) {
|
||||
var value string
|
||||
var probe string
|
||||
var err error
|
||||
|
||||
value = strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
value = "urn:codit:user:"
|
||||
serverIdentifier = strings.TrimSpace(serverIdentifier)
|
||||
if serverIdentifier == "" {
|
||||
serverIdentifier = "codit"
|
||||
}
|
||||
value = "urn:" + serverIdentifier + ":user:"
|
||||
}
|
||||
probe = value + "probe"
|
||||
_, err = url.ParseRequestURI(probe)
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestBuildCRLPEMIncludesOddLengthHexSerials(t *testing.T) {
|
||||
var entries []x509.RevocationListEntry
|
||||
var err error
|
||||
|
||||
certPEM, keyPEM, err = generateRootCA("test-ca", 365)
|
||||
certPEM, keyPEM, err = generateRootCA("test-ca", 365, "Codit Root CA")
|
||||
if err != nil {
|
||||
t.Fatalf("generate root ca: %v", err)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func TestIssueCertFromCAUsesValidSeconds(t *testing.T) {
|
||||
var err error
|
||||
var validity int64
|
||||
|
||||
certPEM, keyPEM, err = generateRootCA("test-ca", 365)
|
||||
certPEM, keyPEM, err = generateRootCA("test-ca", 365, "Codit Root CA")
|
||||
if err != nil {
|
||||
t.Fatalf("generate root ca: %v", err)
|
||||
}
|
||||
|
||||
@@ -786,7 +786,7 @@ func (api *API) DiscoverSSHServerHostKeyForSelf(w http.ResponseWriter, r *http.R
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
discovered, err = discoverSSHServerHostKey(item.Host, item.Port)
|
||||
discovered, err = discoverSSHServerHostKey(item.Host, item.Port, api.serverIdentifier())
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
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()})
|
||||
return
|
||||
}
|
||||
discovered, err = discoverSSHServerHostKey(item.Host, item.Port)
|
||||
discovered, err = discoverSSHServerHostKey(item.Host, item.Port, api.serverIdentifier())
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -1736,7 +1736,7 @@ func parseSSHServerHostKey(raw string) (models.SSHServerHostKey, error) {
|
||||
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 addr string
|
||||
var conn net.Conn
|
||||
@@ -1747,8 +1747,12 @@ func discoverSSHServerHostKey(host string, port int) (models.SSHServerHostKey, e
|
||||
var err error
|
||||
|
||||
addr = net.JoinHostPort(strings.TrimSpace(host), fmt.Sprintf("%d", port))
|
||||
defaultUser = strings.TrimSpace(defaultUser)
|
||||
if defaultUser == "" {
|
||||
defaultUser = "codit"
|
||||
}
|
||||
config = &ssh.ClientConfig{
|
||||
User: "codit",
|
||||
User: defaultUser,
|
||||
Auth: []ssh.AuthMethod{},
|
||||
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
discovered = key
|
||||
|
||||
@@ -12,6 +12,14 @@ import "strings"
|
||||
const sshSecretPrefix string = "enc:v1:"
|
||||
|
||||
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 block cipher.Block
|
||||
var gcm cipher.AEAD
|
||||
@@ -45,7 +53,7 @@ func (api *API) encryptSSHSecretPayload(payload string) (string, error) {
|
||||
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 encoded string
|
||||
var raw []byte
|
||||
|
||||
@@ -771,7 +771,7 @@ func (api *API) signSSHUserKeyWithCA(store *db.Store, item models.SSHUserCA, pub
|
||||
return response, err
|
||||
}
|
||||
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()
|
||||
cert = ssh.Certificate{
|
||||
|
||||
@@ -17,6 +17,7 @@ type meResponse struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Disabled bool `json:"disabled"`
|
||||
AuthSource string `json:"auth_source"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
@@ -172,6 +173,7 @@ func buildMeResponse(user models.User, permissions []string) meResponse {
|
||||
IsAdmin: user.IsAdmin,
|
||||
Disabled: user.Disabled,
|
||||
AuthSource: user.AuthSource,
|
||||
TOTPEnabled: user.TOTPEnabled,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
Permissions: permissions,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -15,6 +15,14 @@ const userKey ctxKey = "user"
|
||||
const principalKey ctxKey = "principal"
|
||||
|
||||
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) {
|
||||
var cookie *http.Cookie
|
||||
var err error
|
||||
@@ -33,7 +41,7 @@ func WithUser(store *db.Store, next http.Handler) http.Handler {
|
||||
if ok && requestStore != nil {
|
||||
activeStore = requestStore
|
||||
}
|
||||
cookie, err = r.Cookie("codit_session")
|
||||
cookie, err = r.Cookie(sessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
token = apiKeyFromRequest(r)
|
||||
if token == "" {
|
||||
|
||||
@@ -8,6 +8,7 @@ type User struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Disabled bool `json:"disabled"`
|
||||
AuthSource string `json:"auth_source"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -23,6 +23,16 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
+93
-30
@@ -18,6 +18,7 @@ import "path/filepath"
|
||||
import "sync"
|
||||
import "strings"
|
||||
import "time"
|
||||
import "unicode"
|
||||
|
||||
import "codit/internal/auth"
|
||||
import codit_config "codit/config"
|
||||
@@ -313,6 +314,7 @@ func storageIDSegment(id int64) string {
|
||||
type Server struct {
|
||||
cfg *codit_config.Config
|
||||
logger Logger
|
||||
serverIdentifier string
|
||||
store *db.Store
|
||||
|
||||
docker_base_dir string
|
||||
@@ -331,6 +333,10 @@ type Server struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 newServerWithInternalConfig(cfg, logger)
|
||||
return newServerWithInternalConfig(cfg, logger, identifier)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 dir string
|
||||
var err error
|
||||
@@ -405,6 +411,7 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger) (*Serv
|
||||
cfg.RPMHTTPPrefix = normalizeHTTPPrefix(cfg.RPMHTTPPrefix, "/rpm")
|
||||
app.cfg = cfg
|
||||
app.logger = logger
|
||||
app.serverIdentifier = NormalizeServerIdentifier(identifier)
|
||||
|
||||
app.docker_base_dir = filepath.Join(cfg.DataDir, "docker")
|
||||
app.git_base_dir = filepath.Join(cfg.DataDir, "git")
|
||||
@@ -441,13 +448,13 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger) (*Serv
|
||||
goto oops
|
||||
}
|
||||
|
||||
err = mergeTLSSettingsFromDB(cfg, app.store)
|
||||
err = mergeTLSSettingsFromDB(cfg, app.store, EnvPrefixFromServerIdentifier(app.serverIdentifier))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tls settings load error: %v", err)
|
||||
goto oops
|
||||
}
|
||||
|
||||
err = bootstrapAdmin(app.store)
|
||||
err = bootstrapAdmin(app.store, EnvPrefixFromServerIdentifier(app.serverIdentifier))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("bootstrap admin error: %v", err)
|
||||
goto oops
|
||||
@@ -537,7 +544,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
|
||||
cancel()
|
||||
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)
|
||||
if extraListenerManager != nil {
|
||||
extraListenerManager.Stop(shutdownCtx)
|
||||
@@ -562,8 +569,8 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
||||
var api *handlers.API
|
||||
var router *codit_http.Router
|
||||
|
||||
// TODO: change api to reference app itself?
|
||||
api = &handlers.API{
|
||||
api = handlers.NewAPI(handlers.APIOptions{
|
||||
ServerIdentifier: app.serverIdentifier,
|
||||
Cfg: *cfg,
|
||||
Store: app.store,
|
||||
Repos: *app.git_manager,
|
||||
@@ -573,10 +580,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
||||
DockerBase: app.docker_base_dir,
|
||||
Uploads: *app.upload_store,
|
||||
Logger: logger,
|
||||
SSHSessionRegistry: handlers.NewSSHSessionRegistry(),
|
||||
SSHPromptedAuthStore: handlers.NewSSHPromptedAuthStore(),
|
||||
SSHPreparedSessionStore: handlers.NewSSHPreparedSessionStore(),
|
||||
}
|
||||
})
|
||||
|
||||
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/me", api.Me)
|
||||
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("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("POST", "/api/users/:id/disable", api.DisableUser)
|
||||
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("POST", "/api/admin/user-groups", api.CreateUserGroup)
|
||||
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,
|
||||
// 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.AccessLog(logger, apiHandler)
|
||||
apiHandler = middleware.WithRequestStore(store, apiHandler)
|
||||
@@ -893,7 +902,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
||||
// middleware.AccessLog(logger,
|
||||
// middleware.WithStoreTransaction(store,
|
||||
// middleware.WithUser(store, router))))
|
||||
apiPublicHandler = middleware.WithUser(store, router)
|
||||
apiPublicHandler = middleware.WithUserCookie(store, sessionCookieNameFromServerIdentifier(app.serverIdentifier), router)
|
||||
apiPublicHandler = middleware.WithStoreTransaction(store, apiPublicHandler)
|
||||
apiPublicHandler = middleware.AccessLog(logger, 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/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
|
||||
}
|
||||
@@ -1932,7 +1941,60 @@ func normalizeHTTPPrefix(value string, fallback string) string {
|
||||
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 cancel context.CancelFunc
|
||||
var wg sync.WaitGroup
|
||||
@@ -1943,6 +2005,7 @@ func serveListeners(ctx context.Context, endpoints []listenerEndpoint, handler h
|
||||
var ep listenerEndpoint
|
||||
var listenErr error
|
||||
var shutdown func()
|
||||
serverIdentifier = NormalizeServerIdentifier(serverIdentifier)
|
||||
if len(endpoints) == 0 {
|
||||
return http.ErrServerClosed
|
||||
}
|
||||
@@ -1967,9 +2030,9 @@ func serveListeners(ctx context.Context, endpoints []listenerEndpoint, handler h
|
||||
for i = 0; i < len(endpoints); i++ {
|
||||
ep = endpoints[i]
|
||||
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 {
|
||||
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)
|
||||
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 envHTTPAddrs string
|
||||
var envHTTPSAddrs string
|
||||
@@ -2167,16 +2230,16 @@ func mergeTLSSettingsFromDB(cfg *codit_config.Config, store *db.Store) error {
|
||||
settings, err = store.GetTLSSettings()
|
||||
if err != nil { return err }
|
||||
|
||||
envHTTPAddrs = strings.TrimSpace(os.Getenv("CODIT_HTTP_ADDRS"))
|
||||
envHTTPSAddrs = strings.TrimSpace(os.Getenv("CODIT_HTTPS_ADDRS"))
|
||||
envServerSource = strings.TrimSpace(os.Getenv("CODIT_TLS_SERVER_CERT_SOURCE"))
|
||||
envCertFile = strings.TrimSpace(os.Getenv("CODIT_TLS_CERT_FILE"))
|
||||
envKeyFile = strings.TrimSpace(os.Getenv("CODIT_TLS_KEY_FILE"))
|
||||
envPKIServerCertID = strings.TrimSpace(os.Getenv("CODIT_TLS_PKI_SERVER_CERT_ID"))
|
||||
envClientAuth = strings.TrimSpace(os.Getenv("CODIT_TLS_CLIENT_AUTH"))
|
||||
envClientCAFile = strings.TrimSpace(os.Getenv("CODIT_TLS_CLIENT_CA_FILE"))
|
||||
envPKIClientCAID = strings.TrimSpace(os.Getenv("CODIT_TLS_PKI_CLIENT_CA_ID"))
|
||||
envTLSMinVersion = strings.TrimSpace(os.Getenv("CODIT_TLS_MIN_VERSION"))
|
||||
envHTTPAddrs = strings.TrimSpace(os.Getenv(envPrefix + "HTTP_ADDRS"))
|
||||
envHTTPSAddrs = strings.TrimSpace(os.Getenv(envPrefix + "HTTPS_ADDRS"))
|
||||
envServerSource = strings.TrimSpace(os.Getenv(envPrefix + "TLS_SERVER_CERT_SOURCE"))
|
||||
envCertFile = strings.TrimSpace(os.Getenv(envPrefix + "TLS_CERT_FILE"))
|
||||
envKeyFile = strings.TrimSpace(os.Getenv(envPrefix + "TLS_KEY_FILE"))
|
||||
envPKIServerCertID = strings.TrimSpace(os.Getenv(envPrefix + "TLS_PKI_SERVER_CERT_ID"))
|
||||
envClientAuth = strings.TrimSpace(os.Getenv(envPrefix + "TLS_CLIENT_AUTH"))
|
||||
envClientCAFile = strings.TrimSpace(os.Getenv(envPrefix + "TLS_CLIENT_CA_FILE"))
|
||||
envPKIClientCAID = strings.TrimSpace(os.Getenv(envPrefix + "TLS_PKI_CLIENT_CA_ID"))
|
||||
envTLSMinVersion = strings.TrimSpace(os.Getenv(envPrefix + "TLS_MIN_VERSION"))
|
||||
if len(settings.HTTPAddrs) > 0 && envHTTPAddrs == "" {
|
||||
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 bootstrap string
|
||||
var parts []string
|
||||
@@ -2256,7 +2319,7 @@ func bootstrapAdmin(store *db.Store) error {
|
||||
var hash string
|
||||
var err error
|
||||
|
||||
bootstrap = os.Getenv("CODIT_BOOTSTRAP_ADMIN")
|
||||
bootstrap = os.Getenv(envPrefix + "BOOTSTRAP_ADMIN")
|
||||
if bootstrap == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_APP_IDENTIFIER=codit
|
||||
VITE_APP_TITLE=Codit
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Codit</title>
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+10
@@ -16,6 +16,7 @@
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
@@ -3433,6 +3434,15 @@
|
||||
"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": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
|
||||
+19
-2
@@ -5,10 +5,20 @@ export interface User {
|
||||
email: string
|
||||
is_admin: boolean
|
||||
auth_source?: string
|
||||
totp_enabled?: boolean
|
||||
disabled?: boolean
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
export type TOTPStatus = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type TOTPSetupResponse = {
|
||||
secret: string
|
||||
otpauth_url: string
|
||||
}
|
||||
|
||||
export type UserCreatePayload = {
|
||||
username: string
|
||||
display_name: string
|
||||
@@ -1117,15 +1127,21 @@ async function requestText(path: string, options: RequestInit = {}): Promise<str
|
||||
|
||||
export const api = {
|
||||
oidcStatus: () => request<OIDCStatus>('/api/auth/oidc/enabled'),
|
||||
login: (username: string, password: string) =>
|
||||
login: (username: string, password: string, otpCode?: string) =>
|
||||
request<User>('/api/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
body: JSON.stringify({ username, password, otp_code: otpCode || '' })
|
||||
}),
|
||||
logout: () => request<void>('/api/logout', { method: 'POST' }),
|
||||
me: () => request<User>('/api/me'),
|
||||
updateMe: (payload: MeUpdatePayload) =>
|
||||
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'),
|
||||
createAPIKey: (name: string, expires_at?: number) =>
|
||||
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' }),
|
||||
disableUser: (id: string) => request<void>(`/api/users/${id}/disable`, { 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'),
|
||||
createUserGroup: (payload: UserGroupUpsertPayload) =>
|
||||
request<UserGroup>('/api/admin/user-groups', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -5,15 +5,16 @@ import { useRoutes } from 'react-router-dom'
|
||||
import { routes } from './routes'
|
||||
import { ThemeModeContext } from './ThemeModeContext'
|
||||
import { createAppTheme, isThemeScheme, ThemeScheme } from './theme'
|
||||
import { storageKey } from '../branding'
|
||||
|
||||
export default function App() {
|
||||
const element = useRoutes(routes)
|
||||
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'
|
||||
})
|
||||
const [scheme, setScheme] = useState<ThemeScheme>(() => {
|
||||
const stored = localStorage.getItem('codit-theme-scheme')
|
||||
const stored = localStorage.getItem(storageKey('theme-scheme'))
|
||||
return isThemeScheme(stored) ? stored : 'blue'
|
||||
})
|
||||
|
||||
@@ -44,8 +45,7 @@ export default function App() {
|
||||
}, [mode])
|
||||
|
||||
const updateMode = (next: 'light' | 'dark') => {
|
||||
localStorage.setItem('codit-theme-mode', next)
|
||||
localStorage.setItem('codit-theme', next)
|
||||
localStorage.setItem(storageKey('theme-mode'), next)
|
||||
setMode(next)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
const updateScheme = (next: ThemeScheme) => {
|
||||
localStorage.setItem('codit-theme-scheme', next)
|
||||
localStorage.setItem(storageKey('theme-scheme'), next)
|
||||
setScheme(next)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { api, User } from '../api'
|
||||
import { appBrand } from '../branding'
|
||||
import MenuIcon from '@mui/icons-material/Menu'
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import WorkspacesIcon from '@mui/icons-material/Workspaces'
|
||||
@@ -161,7 +162,7 @@ export default function Layout() {
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Codit
|
||||
{appBrand.title}
|
||||
</Typography>
|
||||
<Button color="inherit" onClick={openAppearanceMenu} startIcon={<PaletteIcon />} endIcon={<KeyboardArrowDownIcon />}>
|
||||
Appearance
|
||||
|
||||
@@ -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 FormDialogContent from './FormDialogContent'
|
||||
import SelectField from './SelectField'
|
||||
import { appBrand } from '../branding'
|
||||
import { PKICA } from '../api'
|
||||
|
||||
type PKIClientProfileFormOption = {
|
||||
@@ -69,7 +70,7 @@ export default function PKIClientProfileFormDialog(props: PKIClientProfileFormDi
|
||||
label="SAN URI Prefix"
|
||||
value={props.form.sanURIPrefix}
|
||||
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
|
||||
control={<Checkbox checked={props.form.allowServerAuth} onChange={(event) => props.setForm((prev) => ({ ...prev, allowServerAuth: event.target.checked }))} />}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import { Box, Button, Paper, TextField, Typography } from '@mui/material'
|
||||
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() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
@@ -12,6 +13,12 @@ export default function AccountPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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(() => {
|
||||
api
|
||||
@@ -20,10 +27,19 @@ export default function AccountPage() {
|
||||
setUser(me)
|
||||
setDisplayName(me.display_name || '')
|
||||
setEmail(me.email || '')
|
||||
setTOTPEnabled(Boolean(me.totp_enabled))
|
||||
})
|
||||
.catch(() => {
|
||||
setUser(null)
|
||||
})
|
||||
api
|
||||
.getMyTOTP()
|
||||
.then((status) => {
|
||||
setTOTPEnabled(Boolean(status.enabled))
|
||||
})
|
||||
.catch(() => {
|
||||
setTOTPEnabled(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
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 (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||
@@ -96,6 +164,61 @@ export default function AccountPage() {
|
||||
</Box>
|
||||
</Box>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import PKIClientProfileDetailsDialog from '../components/PKIClientProfileDetails
|
||||
import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api'
|
||||
import { appBrand } from '../branding'
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
@@ -50,8 +51,8 @@ function defaultFormState(defaultCAID: string): PKIClientProfileFormState {
|
||||
return {
|
||||
name: '',
|
||||
caID: defaultCAID,
|
||||
subjectOrganization: 'Codit',
|
||||
sanURIPrefix: 'urn:codit:user:',
|
||||
subjectOrganization: appBrand.title,
|
||||
sanURIPrefix: appBrand.sanURIPrefix,
|
||||
allowServerAuth: false,
|
||||
permissionsText: 'read',
|
||||
authzScope: '',
|
||||
@@ -139,8 +140,8 @@ export default function AdminPKIClientProfilesPage() {
|
||||
setForm({
|
||||
name: item.name,
|
||||
caID: item.ca_id,
|
||||
subjectOrganization: item.subject_organization || 'Codit',
|
||||
sanURIPrefix: item.san_uri_prefix || 'urn:codit:user:',
|
||||
subjectOrganization: item.subject_organization || appBrand.title,
|
||||
sanURIPrefix: item.san_uri_prefix || appBrand.sanURIPrefix,
|
||||
allowServerAuth: Boolean(item.allow_server_auth),
|
||||
permissionsText: (item.authz_permissions || []).join(', '),
|
||||
authzScope: item.authz_scope || '',
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -52,6 +45,9 @@ export default function AdminUsersPage() {
|
||||
const [editForm, setEditForm] = useState<UserFormState>(createEmptyForm)
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
const [resetTOTPUser, setResetTOTPUser] = useState<User | null>(null)
|
||||
const [resetTOTPConfirm, setResetTOTPConfirm] = useState('')
|
||||
const [resettingTOTP, setResettingTOTP] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
setEditUser(user)
|
||||
setEditForm({
|
||||
@@ -249,7 +265,7 @@ export default function AdminUsersPage() {
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
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 }}
|
||||
/>
|
||||
<ListRowActions>
|
||||
@@ -263,6 +279,13 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
{u.disabled ? 'Enable' : 'Disable'}
|
||||
</ListRowActionButton>
|
||||
<ListRowActionButton
|
||||
color="warning"
|
||||
onClick={() => setResetTOTPUser(u)}
|
||||
disabled={!u.totp_enabled}
|
||||
>
|
||||
Reset TOTP
|
||||
</ListRowActionButton>
|
||||
<ListRowActionButton color="error" onClick={() => setDeletingUser(u)}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
@@ -305,6 +328,19 @@ export default function AdminUsersPage() {
|
||||
onConfirm={handleDeleteUser}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,16 @@ import { Box, Button, Paper, TextField, Typography } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { appBrand } from '../branding'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [oidcEnabled, setOIDCEnabled] = useState(false)
|
||||
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()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,33 +28,50 @@ export default function LoginPage() {
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
let message: string
|
||||
evt.preventDefault()
|
||||
const data = new FormData(evt.currentTarget)
|
||||
const username = String(data.get('username') || '')
|
||||
const password = String(data.get('password') || '')
|
||||
setError(null)
|
||||
try {
|
||||
await api.login(username, password)
|
||||
await api.login(username, password, otpRequired ? otpCode : '')
|
||||
navigate('/')
|
||||
} 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 (
|
||||
<Paper sx={{ p: 4, maxWidth: 420, mx: 'auto', mt: 12, backgroundColor: 'transparent' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Sign in to Codit
|
||||
Sign in to {appBrand.title}
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleLogin}>
|
||||
<TextField name="username" label="Username" fullWidth margin="normal" />
|
||||
<TextField name="password" label="Password" type="password" 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" 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 && (
|
||||
<Typography color="error" variant="body2">
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
|
||||
Login
|
||||
{otpRequired ? 'Verify' : 'Login'}
|
||||
</Button>
|
||||
{oidcEnabled ? (
|
||||
<>
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user