Compare commits
2 Commits
6cc0c6352f
...
efe82944ab
| Author | SHA1 | Date | |
|---|---|---|---|
| efe82944ab | |||
| 8274a623e4 |
@@ -16,6 +16,9 @@ codit-server.debug:
|
|||||||
codit-data-browser:
|
codit-data-browser:
|
||||||
CGO_ENABLED=0 go build -x -ldflags "-X 'main.CODIT_SERVER_NAME=$(NAME)' -X 'main.CODIT_SERVER_VERSION=$(VERSION)'" -o $@ $(CODIT_DATA_BROWSER_SRCS)
|
CGO_ENABLED=0 go build -x -ldflags "-X 'main.CODIT_SERVER_NAME=$(NAME)' -X 'main.CODIT_SERVER_VERSION=$(VERSION)'" -o $@ $(CODIT_DATA_BROWSER_SRCS)
|
||||||
|
|
||||||
|
check:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
go clean -x
|
go clean -x
|
||||||
rm -rf codit-server codit-server.debug codit-data-browser
|
rm -rf codit-server codit-server.debug codit-data-browser
|
||||||
|
|||||||
@@ -55,20 +55,20 @@ func run() int {
|
|||||||
flag.Var(&configPaths, "config-file-pattern", "wildcard pattern for YAML configuration files; repeatable")
|
flag.Var(&configPaths, "config-file-pattern", "wildcard pattern for YAML configuration files; repeatable")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
logger = codit.NewAsyncLogger("codit", os.Stderr, codit.LOG_ALL)
|
logger = codit.NewAsyncLogger(PROGRAM_NAME, os.Stderr, codit.LOG_ALL)
|
||||||
defer logger.Close()
|
defer logger.Close()
|
||||||
ctx, stop = signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
ctx, stop = signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
logger.Write("", codit.LOG_INFO, "starting %s %s", PROGRAM_NAME, PROGRAM_VERSION)
|
logger.Write("", codit.LOG_INFO, "starting %s %s", PROGRAM_NAME, PROGRAM_VERSION)
|
||||||
defer logger.Write("", codit.LOG_INFO, "stopped %s %s", PROGRAM_NAME, PROGRAM_VERSION)
|
defer logger.Write("", codit.LOG_INFO, "stopped %s %s", PROGRAM_NAME, PROGRAM_VERSION)
|
||||||
|
|
||||||
cfg, err = codit.LoadConfigFilesWithEnvPrefix(configPaths, "CODIT_")
|
cfg, err = codit.LoadConfigFilesWithEnvPrefix(configPaths, codit.EnvPrefixFromServerIdentifier(PROGRAM_NAME))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Write("", codit.LOG_ERROR, "load config: %v", err)
|
logger.Write("", codit.LOG_ERROR, "load config: %v", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err = codit.NewServer(&cfg, logger)
|
server, err = codit.NewServerWithIdentifier(&cfg, logger, PROGRAM_NAME)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Write("", codit.LOG_ERROR, "init app: %v", err)
|
logger.Write("", codit.LOG_ERROR, "init app: %v", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -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 created time.Time
|
||||||
var updated time.Time
|
var updated time.Time
|
||||||
var err error
|
var err error
|
||||||
row = s.QueryRow(`SELECT public_id, username, display_name, email, is_admin, disabled, auth_source, created_at, updated_at FROM users WHERE public_id = ?`, id)
|
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, COALESCE(t.enabled, 0), u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.public_id = ?`, id)
|
||||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &created, &updated)
|
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &created, &updated)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
@@ -101,8 +101,8 @@ func (s *Store) GetUserByUsername(username string) (models.User, string, error)
|
|||||||
var err error
|
var err error
|
||||||
var created time.Time
|
var created time.Time
|
||||||
var updated time.Time
|
var updated time.Time
|
||||||
row = s.QueryRow(`SELECT public_id, username, display_name, email, is_admin, disabled, auth_source, password_hash, created_at, updated_at FROM users WHERE username = ?`, username)
|
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, u.password_hash, COALESCE(t.enabled, 0), u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.username = ?`, username)
|
||||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &created, &updated)
|
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &user.TOTPEnabled, &created, &updated)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return user, passwordHash.String, err
|
return user, passwordHash.String, err
|
||||||
}
|
}
|
||||||
@@ -118,13 +118,13 @@ func (s *Store) ListUsers() ([]models.User, error) {
|
|||||||
var u models.User
|
var u models.User
|
||||||
var created time.Time
|
var created time.Time
|
||||||
var updated time.Time
|
var updated time.Time
|
||||||
rows, err = s.Query(`SELECT public_id, username, display_name, email, is_admin, disabled, auth_source, created_at, updated_at FROM users ORDER BY username`)
|
rows, err = s.Query(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, COALESCE(t.enabled, 0), u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id ORDER BY u.username`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthSource, &created, &updated)
|
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthSource, &u.TOTPEnabled, &created, &updated)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -874,11 +874,12 @@ func (s *Store) GetSessionUser(token string) (models.User, time.Time, error) {
|
|||||||
var created time.Time
|
var created time.Time
|
||||||
var updated time.Time
|
var updated time.Time
|
||||||
row = s.QueryRow(`
|
row = s.QueryRow(`
|
||||||
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, u.created_at, u.updated_at, s.expires_at
|
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, COALESCE(t.enabled, 0), u.created_at, u.updated_at, s.expires_at
|
||||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||||
|
LEFT JOIN user_totp t ON t.user_id = u.id
|
||||||
WHERE s.token = ? AND u.disabled = 0
|
WHERE s.token = ? AND u.disabled = 0
|
||||||
`, token)
|
`, token)
|
||||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &created, &updated, &expires)
|
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &created, &updated, &expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return user, time.Time{}, err
|
return user, time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
return nil, nil, profile, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
client = &acme.Client{Key: key, DirectoryURL: strings.TrimSpace(profile.DirectoryURL), UserAgent: "codit-acme"}
|
client = &acme.Client{Key: key, DirectoryURL: strings.TrimSpace(profile.DirectoryURL), UserAgent: api.serverIdentifier()+"-acme"}
|
||||||
if strings.TrimSpace(profile.AccountURL) != "" {
|
if strings.TrimSpace(profile.AccountURL) != "" {
|
||||||
client.KID = acme.KeyID(strings.TrimSpace(profile.AccountURL))
|
client.KID = acme.KeyID(strings.TrimSpace(profile.AccountURL))
|
||||||
return client, key, profile, nil
|
return client, key, profile, nil
|
||||||
|
|||||||
@@ -30,7 +30,26 @@ import "codit/internal/storage"
|
|||||||
import "codit/internal/util"
|
import "codit/internal/util"
|
||||||
import codit_logger "codit/logger"
|
import codit_logger "codit/logger"
|
||||||
|
|
||||||
|
type APIOptions struct {
|
||||||
|
ServerIdentifier string
|
||||||
|
Cfg config.Config
|
||||||
|
Store *db.Store
|
||||||
|
Repos git.RepoManager
|
||||||
|
RpmBase string
|
||||||
|
RpmMeta *rpm.MetaManager
|
||||||
|
RpmMirror *rpm.MirrorManager
|
||||||
|
DockerBase string
|
||||||
|
Uploads storage.FileStore
|
||||||
|
Logger codit_logger.Logger
|
||||||
|
OnTLSListenersChanged func()
|
||||||
|
OnTLSListenerRuntimeStatus func() map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
type API struct {
|
type API struct {
|
||||||
|
ServerIdentifier string
|
||||||
|
ServerTitle string
|
||||||
|
SessionCookieName string
|
||||||
|
OIDCStateCookieName string
|
||||||
Cfg config.Config
|
Cfg config.Config
|
||||||
Store *db.Store
|
Store *db.Store
|
||||||
Repos git.RepoManager
|
Repos git.RepoManager
|
||||||
@@ -47,11 +66,70 @@ type API struct {
|
|||||||
OnTLSListenerRuntimeStatus func() map[string]int
|
OnTLSListenerRuntimeStatus func() map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewAPI(options APIOptions) *API {
|
||||||
|
var identifier string
|
||||||
|
var title string
|
||||||
|
var api *API
|
||||||
|
|
||||||
|
identifier = strings.ToLower(strings.TrimSpace(options.ServerIdentifier))
|
||||||
|
identifier = strings.ReplaceAll(identifier, "_", "-")
|
||||||
|
identifier = strings.Trim(identifier, "-")
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = "codit"
|
||||||
|
}
|
||||||
|
title = strings.TrimSpace(options.ServerIdentifier)
|
||||||
|
if title == "" {
|
||||||
|
title = "Codit"
|
||||||
|
}
|
||||||
|
api = &API{
|
||||||
|
ServerIdentifier: identifier,
|
||||||
|
ServerTitle: title,
|
||||||
|
SessionCookieName: strings.ReplaceAll(identifier, "-", "_") + "_session",
|
||||||
|
OIDCStateCookieName: strings.ReplaceAll(identifier, "-", "_") + "_oidc_state",
|
||||||
|
Cfg: options.Cfg,
|
||||||
|
Store: options.Store,
|
||||||
|
Repos: options.Repos,
|
||||||
|
RpmBase: options.RpmBase,
|
||||||
|
RpmMeta: options.RpmMeta,
|
||||||
|
RpmMirror: options.RpmMirror,
|
||||||
|
DockerBase: options.DockerBase,
|
||||||
|
Uploads: options.Uploads,
|
||||||
|
Logger: options.Logger,
|
||||||
|
SSHSessionRegistry: NewSSHSessionRegistry(),
|
||||||
|
SSHPromptedAuthStore: NewSSHPromptedAuthStore(),
|
||||||
|
SSHPreparedSessionStore: NewSSHPreparedSessionStore(),
|
||||||
|
OnTLSListenersChanged: options.OnTLSListenersChanged,
|
||||||
|
OnTLSListenerRuntimeStatus: options.OnTLSListenerRuntimeStatus,
|
||||||
|
}
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) serverIdentifier() string {
|
||||||
|
if strings.TrimSpace(api.ServerIdentifier) == "" { return "codit" }
|
||||||
|
return strings.TrimSpace(api.ServerIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) serverTitle() string {
|
||||||
|
if strings.TrimSpace(api.ServerTitle) == "" { return "Codit" }
|
||||||
|
return strings.TrimSpace(api.ServerTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) sessionCookieName() string {
|
||||||
|
if strings.TrimSpace(api.SessionCookieName) == "" { return "codit_session" }
|
||||||
|
return strings.TrimSpace(api.SessionCookieName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) oidcStateCookieName() string {
|
||||||
|
if strings.TrimSpace(api.OIDCStateCookieName) == "" { return "codit_oidc_state" }
|
||||||
|
return strings.TrimSpace(api.OIDCStateCookieName)
|
||||||
|
}
|
||||||
|
|
||||||
const logIDAuth string = "auth"
|
const logIDAuth string = "auth"
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
OTPCode string `json:"otp_code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type createUserRequest struct {
|
type createUserRequest struct {
|
||||||
@@ -380,8 +458,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == nil && storedHash != "" && auth.ComparePassword(storedHash, req.Password) == nil {
|
if err == nil && storedHash != "" && auth.ComparePassword(storedHash, req.Password) == nil {
|
||||||
api.issueSession(w, r, user)
|
api.finishLogin(w, r, user, req.OTPCode)
|
||||||
WriteJSON(w, http.StatusOK, user)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,8 +487,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
|
|||||||
user = created
|
user = created
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
api.issueSession(w, r, user)
|
api.finishLogin(w, r, user, req.OTPCode)
|
||||||
WriteJSON(w, http.StatusOK, user)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap login failed username=%s err=%v", req.Username, err)
|
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap login failed username=%s err=%v", req.Username, err)
|
||||||
@@ -433,7 +509,7 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
|
|||||||
_ = api.store(r).DeleteExpiredSessions(time.Now().UTC())
|
_ = api.store(r).DeleteExpiredSessions(time.Now().UTC())
|
||||||
_ = api.store(r).CreateSession(user.ID, token, expires)
|
_ = api.store(r).CreateSession(user.ID, token, expires)
|
||||||
cookie = &http.Cookie{
|
cookie = &http.Cookie{
|
||||||
Name: "codit_session",
|
Name: api.sessionCookieName(),
|
||||||
Value: token,
|
Value: token,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
@@ -445,12 +521,12 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
|
|||||||
func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
var cookie *http.Cookie
|
var cookie *http.Cookie
|
||||||
var err error
|
var err error
|
||||||
cookie, err = r.Cookie("codit_session")
|
cookie, err = r.Cookie(api.sessionCookieName())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = api.store(r).DeleteSession(cookie.Value)
|
_ = api.store(r).DeleteSession(cookie.Value)
|
||||||
}
|
}
|
||||||
cookie = &http.Cookie{
|
cookie = &http.Cookie{
|
||||||
Name: "codit_session",
|
Name: api.sessionCookieName(),
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Expires: time.Unix(0, 0),
|
Expires: time.Unix(0, 0),
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (api *API) OIDCLogin(w http.ResponseWriter, r *http.Request, _ map[string]s
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "codit_oidc_state",
|
Name: api.oidcStateCookieName(),
|
||||||
Value: state,
|
Value: state,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Path: "/api/auth/oidc",
|
Path: "/api/auth/oidc",
|
||||||
@@ -125,8 +125,8 @@ func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[strin
|
|||||||
}
|
}
|
||||||
state = strings.TrimSpace(r.URL.Query().Get("state"))
|
state = strings.TrimSpace(r.URL.Query().Get("state"))
|
||||||
code = strings.TrimSpace(r.URL.Query().Get("code"))
|
code = strings.TrimSpace(r.URL.Query().Get("code"))
|
||||||
cookie, err = r.Cookie("codit_oidc_state")
|
cookie, err = r.Cookie(api.oidcStateCookieName())
|
||||||
clearOIDCStateCookie(w)
|
api.clearOIDCStateCookie(w)
|
||||||
if err != nil || cookie.Value == "" || state == "" || state != cookie.Value {
|
if err != nil || cookie.Value == "" || state == "" || state != cookie.Value {
|
||||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid oidc state"})
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid oidc state"})
|
||||||
return
|
return
|
||||||
@@ -411,9 +411,9 @@ func newOIDCState() (string, error) {
|
|||||||
return hex.EncodeToString(buf), nil
|
return hex.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearOIDCStateCookie(w http.ResponseWriter) {
|
func (api *API) clearOIDCStateCookie(w http.ResponseWriter) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "codit_oidc_state",
|
Name: api.oidcStateCookieName(),
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/api/auth/oidc",
|
Path: "/api/auth/oidc",
|
||||||
Expires: time.Unix(0, 0),
|
Expires: time.Unix(0, 0),
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ func (api *API) CreatePKIRootCA(w http.ResponseWriter, r *http.Request, _ map[st
|
|||||||
keyPEM = req.KeyPEM
|
keyPEM = req.KeyPEM
|
||||||
_ = key
|
_ = key
|
||||||
} else {
|
} else {
|
||||||
certPEM, keyPEM, err = generateRootCA(strings.TrimSpace(req.CommonName), req.Days)
|
certPEM, keyPEM, err = generateRootCA(strings.TrimSpace(req.CommonName), req.Days, api.serverTitle()+" Root CA")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -1209,7 +1209,7 @@ func parseTLSCertificatePair(certPEM string, keyPEM string) (*x509.Certificate,
|
|||||||
return parsedCert, nil
|
return parsedCert, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateRootCA(commonName string, days int) (string, string, error) {
|
func generateRootCA(commonName string, days int, defaultCommonName string) (string, string, error) {
|
||||||
var key *rsa.PrivateKey
|
var key *rsa.PrivateKey
|
||||||
var now time.Time
|
var now time.Time
|
||||||
var end time.Time
|
var end time.Time
|
||||||
@@ -1219,6 +1219,9 @@ func generateRootCA(commonName string, days int) (string, string, error) {
|
|||||||
var certPEM string
|
var certPEM string
|
||||||
var keyPEM string
|
var keyPEM string
|
||||||
var err error
|
var err error
|
||||||
|
if strings.TrimSpace(commonName) == "" {
|
||||||
|
commonName = strings.TrimSpace(defaultCommonName)
|
||||||
|
}
|
||||||
if strings.TrimSpace(commonName) == "" {
|
if strings.TrimSpace(commonName) == "" {
|
||||||
commonName = "Codit Root CA"
|
commonName = "Codit Root CA"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,8 +186,8 @@ func (api *API) CreatePKIClientProfile(w http.ResponseWriter, r *http.Request, _
|
|||||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization)
|
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization, api.serverTitle())
|
||||||
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix)
|
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix, api.serverIdentifier())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -274,8 +274,8 @@ func (api *API) UpdatePKIClientProfile(w http.ResponseWriter, r *http.Request, p
|
|||||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization)
|
req.SubjectOrganization = normalizePKIClientSubjectOrganization(req.SubjectOrganization, api.serverTitle())
|
||||||
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix)
|
req.SANURIPrefix, err = normalizePKIClientSANURIPrefix(req.SANURIPrefix, api.serverIdentifier())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -792,23 +792,25 @@ func normalizePKIClientProfileTargets(raw []pkiClientProfileTargetRequest) ([]mo
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePKIClientSubjectOrganization(raw string) string {
|
func normalizePKIClientSubjectOrganization(raw string, defaultValue string) string {
|
||||||
var value string
|
var value string
|
||||||
value = strings.TrimSpace(raw)
|
value = strings.TrimSpace(raw)
|
||||||
if value == "" {
|
if value == "" { return defaultValue }
|
||||||
return "Codit"
|
|
||||||
}
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePKIClientSANURIPrefix(raw string) (string, error) {
|
func normalizePKIClientSANURIPrefix(raw string, serverIdentifier string) (string, error) {
|
||||||
var value string
|
var value string
|
||||||
var probe string
|
var probe string
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
value = strings.TrimSpace(raw)
|
value = strings.TrimSpace(raw)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
value = "urn:codit:user:"
|
serverIdentifier = strings.TrimSpace(serverIdentifier)
|
||||||
|
if serverIdentifier == "" {
|
||||||
|
serverIdentifier = "codit"
|
||||||
|
}
|
||||||
|
value = "urn:" + serverIdentifier + ":user:"
|
||||||
}
|
}
|
||||||
probe = value + "probe"
|
probe = value + "probe"
|
||||||
_, err = url.ParseRequestURI(probe)
|
_, err = url.ParseRequestURI(probe)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestBuildCRLPEMIncludesOddLengthHexSerials(t *testing.T) {
|
|||||||
var entries []x509.RevocationListEntry
|
var entries []x509.RevocationListEntry
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
certPEM, keyPEM, err = generateRootCA("test-ca", 365)
|
certPEM, keyPEM, err = generateRootCA("test-ca", 365, "Codit Root CA")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generate root ca: %v", err)
|
t.Fatalf("generate root ca: %v", err)
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ func TestIssueCertFromCAUsesValidSeconds(t *testing.T) {
|
|||||||
var err error
|
var err error
|
||||||
var validity int64
|
var validity int64
|
||||||
|
|
||||||
certPEM, keyPEM, err = generateRootCA("test-ca", 365)
|
certPEM, keyPEM, err = generateRootCA("test-ca", 365, "Codit Root CA")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generate root ca: %v", err)
|
t.Fatalf("generate root ca: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -786,7 +786,7 @@ func (api *API) DiscoverSSHServerHostKeyForSelf(w http.ResponseWriter, r *http.R
|
|||||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
discovered, err = discoverSSHServerHostKey(item.Host, item.Port)
|
discovered, err = discoverSSHServerHostKey(item.Host, item.Port, api.serverIdentifier())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
WriteJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -1028,7 +1028,7 @@ func (api *API) DiscoverSSHServerHostKeyAdmin(w http.ResponseWriter, r *http.Req
|
|||||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
discovered, err = discoverSSHServerHostKey(item.Host, item.Port)
|
discovered, err = discoverSSHServerHostKey(item.Host, item.Port, api.serverIdentifier())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
WriteJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -1736,7 +1736,7 @@ func parseSSHServerHostKey(raw string) (models.SSHServerHostKey, error) {
|
|||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func discoverSSHServerHostKey(host string, port int) (models.SSHServerHostKey, error) {
|
func discoverSSHServerHostKey(host string, port int, defaultUser string) (models.SSHServerHostKey, error) {
|
||||||
var item models.SSHServerHostKey
|
var item models.SSHServerHostKey
|
||||||
var addr string
|
var addr string
|
||||||
var conn net.Conn
|
var conn net.Conn
|
||||||
@@ -1747,8 +1747,12 @@ func discoverSSHServerHostKey(host string, port int) (models.SSHServerHostKey, e
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
addr = net.JoinHostPort(strings.TrimSpace(host), fmt.Sprintf("%d", port))
|
addr = net.JoinHostPort(strings.TrimSpace(host), fmt.Sprintf("%d", port))
|
||||||
|
defaultUser = strings.TrimSpace(defaultUser)
|
||||||
|
if defaultUser == "" {
|
||||||
|
defaultUser = "codit"
|
||||||
|
}
|
||||||
config = &ssh.ClientConfig{
|
config = &ssh.ClientConfig{
|
||||||
User: "codit",
|
User: defaultUser,
|
||||||
Auth: []ssh.AuthMethod{},
|
Auth: []ssh.AuthMethod{},
|
||||||
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
discovered = key
|
discovered = key
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ import "strings"
|
|||||||
const sshSecretPrefix string = "enc:v1:"
|
const sshSecretPrefix string = "enc:v1:"
|
||||||
|
|
||||||
func (api *API) encryptSSHSecretPayload(payload string) (string, error) {
|
func (api *API) encryptSSHSecretPayload(payload string) (string, error) {
|
||||||
|
return api.encryptSecretPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) decryptSSHSecretPayload(payload string) (string, error) {
|
||||||
|
return api.decryptSecretPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) encryptSecretPayload(payload string) (string, error) {
|
||||||
var key []byte
|
var key []byte
|
||||||
var block cipher.Block
|
var block cipher.Block
|
||||||
var gcm cipher.AEAD
|
var gcm cipher.AEAD
|
||||||
@@ -45,7 +53,7 @@ func (api *API) encryptSSHSecretPayload(payload string) (string, error) {
|
|||||||
return sshSecretPrefix + base64.StdEncoding.EncodeToString(raw), nil
|
return sshSecretPrefix + base64.StdEncoding.EncodeToString(raw), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) decryptSSHSecretPayload(payload string) (string, error) {
|
func (api *API) decryptSecretPayload(payload string) (string, error) {
|
||||||
var key []byte
|
var key []byte
|
||||||
var encoded string
|
var encoded string
|
||||||
var raw []byte
|
var raw []byte
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ func (api *API) signSSHUserKeyWithCA(store *db.Store, item models.SSHUserCA, pub
|
|||||||
return response, err
|
return response, err
|
||||||
}
|
}
|
||||||
if keyID == "" {
|
if keyID == "" {
|
||||||
keyID = fmt.Sprintf("codit-%s-%d", item.Name, serial)
|
keyID = fmt.Sprintf("%s-%s-%d", api.serverIdentifier(), item.Name, serial)
|
||||||
}
|
}
|
||||||
now = time.Now().UTC().Unix()
|
now = time.Now().UTC().Unix()
|
||||||
cert = ssh.Certificate{
|
cert = ssh.Certificate{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type meResponse struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
AuthSource string `json:"auth_source"`
|
AuthSource string `json:"auth_source"`
|
||||||
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
Permissions []string `json:"permissions,omitempty"`
|
Permissions []string `json:"permissions,omitempty"`
|
||||||
@@ -172,6 +173,7 @@ func buildMeResponse(user models.User, permissions []string) meResponse {
|
|||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
Disabled: user.Disabled,
|
Disabled: user.Disabled,
|
||||||
AuthSource: user.AuthSource,
|
AuthSource: user.AuthSource,
|
||||||
|
TOTPEnabled: user.TOTPEnabled,
|
||||||
CreatedAt: user.CreatedAt,
|
CreatedAt: user.CreatedAt,
|
||||||
UpdatedAt: user.UpdatedAt,
|
UpdatedAt: user.UpdatedAt,
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
|
|||||||
@@ -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"
|
const principalKey ctxKey = "principal"
|
||||||
|
|
||||||
func WithUser(store *db.Store, next http.Handler) http.Handler {
|
func WithUser(store *db.Store, next http.Handler) http.Handler {
|
||||||
|
return WithUserCookie(store, "codit_session", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithUserCookie(store *db.Store, sessionCookieName string, next http.Handler) http.Handler {
|
||||||
|
sessionCookieName = strings.TrimSpace(sessionCookieName)
|
||||||
|
if sessionCookieName == "" {
|
||||||
|
sessionCookieName = "codit_session"
|
||||||
|
}
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var cookie *http.Cookie
|
var cookie *http.Cookie
|
||||||
var err error
|
var err error
|
||||||
@@ -33,7 +41,7 @@ func WithUser(store *db.Store, next http.Handler) http.Handler {
|
|||||||
if ok && requestStore != nil {
|
if ok && requestStore != nil {
|
||||||
activeStore = requestStore
|
activeStore = requestStore
|
||||||
}
|
}
|
||||||
cookie, err = r.Cookie("codit_session")
|
cookie, err = r.Cookie(sessionCookieName)
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
token = apiKeyFromRequest(r)
|
token = apiKeyFromRequest(r)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
AuthSource string `json:"auth_source"`
|
AuthSource string `json:"auth_source"`
|
||||||
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_totp (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
secret_encrypted TEXT NOT NULL DEFAULT '',
|
||||||
|
pending_secret_encrypted TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
public_id TEXT NOT NULL UNIQUE,
|
public_id TEXT NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS user_totp (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
secret_encrypted TEXT NOT NULL DEFAULT '',
|
||||||
|
pending_secret_encrypted TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
+102
-39
@@ -18,6 +18,7 @@ import "path/filepath"
|
|||||||
import "sync"
|
import "sync"
|
||||||
import "strings"
|
import "strings"
|
||||||
import "time"
|
import "time"
|
||||||
|
import "unicode"
|
||||||
|
|
||||||
import "codit/internal/auth"
|
import "codit/internal/auth"
|
||||||
import codit_config "codit/config"
|
import codit_config "codit/config"
|
||||||
@@ -313,6 +314,7 @@ func storageIDSegment(id int64) string {
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
cfg *codit_config.Config
|
cfg *codit_config.Config
|
||||||
logger Logger
|
logger Logger
|
||||||
|
serverIdentifier string
|
||||||
store *db.Store
|
store *db.Store
|
||||||
|
|
||||||
docker_base_dir string
|
docker_base_dir string
|
||||||
@@ -331,6 +333,10 @@ type Server struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *Config, logger Logger) (*Server, error) {
|
func NewServer(cfg *Config, logger Logger) (*Server, error) {
|
||||||
|
return NewServerWithIdentifier(cfg, logger, "codit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServerWithIdentifier(cfg *Config, logger Logger, identifier string) (*Server, error) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil, errors.New("config is required")
|
return nil, errors.New("config is required")
|
||||||
}
|
}
|
||||||
@@ -338,7 +344,7 @@ func NewServer(cfg *Config, logger Logger) (*Server, error) {
|
|||||||
return nil, errors.New("logger is required")
|
return nil, errors.New("logger is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
return newServerWithInternalConfig(cfg, logger)
|
return newServerWithInternalConfig(cfg, logger, identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func auth_user(store *db.Store, username string, password string) (bool, error) {
|
func auth_user(store *db.Store, username string, password string) (bool, error) {
|
||||||
@@ -395,7 +401,7 @@ func auth_user(store *db.Store, username string, password string) (bool, error)
|
|||||||
return user.ID != "", nil
|
return user.ID != "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger) (*Server, error) {
|
func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identifier string) (*Server, error) {
|
||||||
var app Server
|
var app Server
|
||||||
var dir string
|
var dir string
|
||||||
var err error
|
var err error
|
||||||
@@ -405,6 +411,7 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger) (*Serv
|
|||||||
cfg.RPMHTTPPrefix = normalizeHTTPPrefix(cfg.RPMHTTPPrefix, "/rpm")
|
cfg.RPMHTTPPrefix = normalizeHTTPPrefix(cfg.RPMHTTPPrefix, "/rpm")
|
||||||
app.cfg = cfg
|
app.cfg = cfg
|
||||||
app.logger = logger
|
app.logger = logger
|
||||||
|
app.serverIdentifier = NormalizeServerIdentifier(identifier)
|
||||||
|
|
||||||
app.docker_base_dir = filepath.Join(cfg.DataDir, "docker")
|
app.docker_base_dir = filepath.Join(cfg.DataDir, "docker")
|
||||||
app.git_base_dir = filepath.Join(cfg.DataDir, "git")
|
app.git_base_dir = filepath.Join(cfg.DataDir, "git")
|
||||||
@@ -441,13 +448,13 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger) (*Serv
|
|||||||
goto oops
|
goto oops
|
||||||
}
|
}
|
||||||
|
|
||||||
err = mergeTLSSettingsFromDB(cfg, app.store)
|
err = mergeTLSSettingsFromDB(cfg, app.store, EnvPrefixFromServerIdentifier(app.serverIdentifier))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("tls settings load error: %v", err)
|
err = fmt.Errorf("tls settings load error: %v", err)
|
||||||
goto oops
|
goto oops
|
||||||
}
|
}
|
||||||
|
|
||||||
err = bootstrapAdmin(app.store)
|
err = bootstrapAdmin(app.store, EnvPrefixFromServerIdentifier(app.serverIdentifier))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("bootstrap admin error: %v", err)
|
err = fmt.Errorf("bootstrap admin error: %v", err)
|
||||||
goto oops
|
goto oops
|
||||||
@@ -537,7 +544,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
|
|||||||
cancel()
|
cancel()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = serveListeners(ctx, mainEndpoints, mux, app.logger)
|
err = serveListeners(ctx, mainEndpoints, mux, app.logger, app.serverIdentifier)
|
||||||
shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
if extraListenerManager != nil {
|
if extraListenerManager != nil {
|
||||||
extraListenerManager.Stop(shutdownCtx)
|
extraListenerManager.Stop(shutdownCtx)
|
||||||
@@ -562,21 +569,18 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
|||||||
var api *handlers.API
|
var api *handlers.API
|
||||||
var router *codit_http.Router
|
var router *codit_http.Router
|
||||||
|
|
||||||
// TODO: change api to reference app itself?
|
api = handlers.NewAPI(handlers.APIOptions{
|
||||||
api = &handlers.API{
|
ServerIdentifier: app.serverIdentifier,
|
||||||
Cfg: *cfg,
|
Cfg: *cfg,
|
||||||
Store: app.store,
|
Store: app.store,
|
||||||
Repos: *app.git_manager,
|
Repos: *app.git_manager,
|
||||||
RpmBase: app.rpm_base_dir,
|
RpmBase: app.rpm_base_dir,
|
||||||
RpmMeta: app.rpm_meta,
|
RpmMeta: app.rpm_meta,
|
||||||
RpmMirror: app.rpm_mirror,
|
RpmMirror: app.rpm_mirror,
|
||||||
DockerBase: app.docker_base_dir,
|
DockerBase: app.docker_base_dir,
|
||||||
Uploads: *app.upload_store,
|
Uploads: *app.upload_store,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
SSHSessionRegistry: handlers.NewSSHSessionRegistry(),
|
})
|
||||||
SSHPromptedAuthStore: handlers.NewSSHPromptedAuthStore(),
|
|
||||||
SSHPreparedSessionStore: handlers.NewSSHPreparedSessionStore(),
|
|
||||||
}
|
|
||||||
|
|
||||||
router = codit_http.NewRouter()
|
router = codit_http.NewRouter()
|
||||||
|
|
||||||
@@ -588,6 +592,10 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
|||||||
router.Handle("GET", "/api/auth/oidc/callback", api.OIDCCallback)
|
router.Handle("GET", "/api/auth/oidc/callback", api.OIDCCallback)
|
||||||
router.Handle("GET", "/api/me", api.Me)
|
router.Handle("GET", "/api/me", api.Me)
|
||||||
router.Handle("PATCH", "/api/me", api.UpdateMe)
|
router.Handle("PATCH", "/api/me", api.UpdateMe)
|
||||||
|
router.Handle("GET", "/api/me/totp", api.GetMyTOTP)
|
||||||
|
router.Handle("POST", "/api/me/totp/setup", api.SetupMyTOTP)
|
||||||
|
router.Handle("POST", "/api/me/totp/enable", api.EnableMyTOTP)
|
||||||
|
router.Handle("POST", "/api/me/totp/disable", api.DisableMyTOTP)
|
||||||
|
|
||||||
router.Handle("GET", "/api/users", api.ListUsers)
|
router.Handle("GET", "/api/users", api.ListUsers)
|
||||||
router.Handle("POST", "/api/users", api.CreateUser)
|
router.Handle("POST", "/api/users", api.CreateUser)
|
||||||
@@ -595,6 +603,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
|||||||
router.Handle("DELETE", "/api/users/:id", api.DeleteUser)
|
router.Handle("DELETE", "/api/users/:id", api.DeleteUser)
|
||||||
router.Handle("POST", "/api/users/:id/disable", api.DisableUser)
|
router.Handle("POST", "/api/users/:id/disable", api.DisableUser)
|
||||||
router.Handle("POST", "/api/users/:id/enable", api.EnableUser)
|
router.Handle("POST", "/api/users/:id/enable", api.EnableUser)
|
||||||
|
router.Handle("POST", "/api/users/:id/totp/reset", api.ResetUserTOTPAdmin)
|
||||||
router.Handle("GET", "/api/admin/user-groups", api.ListUserGroups)
|
router.Handle("GET", "/api/admin/user-groups", api.ListUserGroups)
|
||||||
router.Handle("POST", "/api/admin/user-groups", api.CreateUserGroup)
|
router.Handle("POST", "/api/admin/user-groups", api.CreateUserGroup)
|
||||||
router.Handle("PATCH", "/api/admin/user-groups/:id", api.UpdateUserGroup)
|
router.Handle("PATCH", "/api/admin/user-groups/:id", api.UpdateUserGroup)
|
||||||
@@ -881,7 +890,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
|||||||
// middleware.WithUser(store,
|
// middleware.WithUser(store,
|
||||||
// withAPIAuth(router, auth_user, store, logger)))))
|
// withAPIAuth(router, auth_user, store, logger)))))
|
||||||
apiHandler = withAPIAuth(router, auth_user, store, logger)
|
apiHandler = withAPIAuth(router, auth_user, store, logger)
|
||||||
apiHandler = middleware.WithUser(store, apiHandler)
|
apiHandler = middleware.WithUserCookie(store, sessionCookieNameFromServerIdentifier(app.serverIdentifier), apiHandler)
|
||||||
apiHandler = middleware.WithStoreTransaction(store, apiHandler)
|
apiHandler = middleware.WithStoreTransaction(store, apiHandler)
|
||||||
apiHandler = middleware.AccessLog(logger, apiHandler)
|
apiHandler = middleware.AccessLog(logger, apiHandler)
|
||||||
apiHandler = middleware.WithRequestStore(store, apiHandler)
|
apiHandler = middleware.WithRequestStore(store, apiHandler)
|
||||||
@@ -893,7 +902,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
|||||||
// middleware.AccessLog(logger,
|
// middleware.AccessLog(logger,
|
||||||
// middleware.WithStoreTransaction(store,
|
// middleware.WithStoreTransaction(store,
|
||||||
// middleware.WithUser(store, router))))
|
// middleware.WithUser(store, router))))
|
||||||
apiPublicHandler = middleware.WithUser(store, router)
|
apiPublicHandler = middleware.WithUserCookie(store, sessionCookieNameFromServerIdentifier(app.serverIdentifier), router)
|
||||||
apiPublicHandler = middleware.WithStoreTransaction(store, apiPublicHandler)
|
apiPublicHandler = middleware.WithStoreTransaction(store, apiPublicHandler)
|
||||||
apiPublicHandler = middleware.AccessLog(logger, apiPublicHandler)
|
apiPublicHandler = middleware.AccessLog(logger, apiPublicHandler)
|
||||||
apiPublicHandler = middleware.WithRequestStore(store, apiPublicHandler)
|
apiPublicHandler = middleware.WithRequestStore(store, apiPublicHandler)
|
||||||
@@ -904,7 +913,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
|||||||
mux.Handle("/api/auth/oidc/login", apiPublicHandler)
|
mux.Handle("/api/auth/oidc/login", apiPublicHandler)
|
||||||
mux.Handle("/api/auth/oidc/callback", apiPublicHandler)
|
mux.Handle("/api/auth/oidc/callback", apiPublicHandler)
|
||||||
|
|
||||||
mux.Handle("/", middleware.WithUser(store, spaHandler(cfg.FrontendDir)))
|
mux.Handle("/", middleware.WithUserCookie(store, sessionCookieNameFromServerIdentifier(app.serverIdentifier), spaHandler(cfg.FrontendDir)))
|
||||||
|
|
||||||
return api, mux, nil
|
return api, mux, nil
|
||||||
}
|
}
|
||||||
@@ -1932,7 +1941,60 @@ func normalizeHTTPPrefix(value string, fallback string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveListeners(ctx context.Context, endpoints []listenerEndpoint, handler http.Handler, logger Logger) error {
|
func sessionCookieNameFromServerIdentifier(value string) string {
|
||||||
|
return strings.ReplaceAll(NormalizeServerIdentifier(value), "-", "_") + "_session"
|
||||||
|
}
|
||||||
|
|
||||||
|
func oidcStateCookieNameFromServerIdentifier(value string) string {
|
||||||
|
return strings.ReplaceAll(NormalizeServerIdentifier(value), "-", "_") + "_oidc_state"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeServerIdentifier(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
value = strings.ReplaceAll(value, "_", "-")
|
||||||
|
value = strings.Trim(value, "-")
|
||||||
|
if value == "" {
|
||||||
|
return "codit"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnvPrefixFromServerIdentifier(value string) string {
|
||||||
|
var prefix string
|
||||||
|
prefix = strings.ToUpper(strings.ReplaceAll(NormalizeServerIdentifier(value), "-", "_"))
|
||||||
|
if !strings.HasSuffix(prefix, "_") {
|
||||||
|
prefix = prefix + "_"
|
||||||
|
}
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func TitleFromServerIdentifier(value string) string {
|
||||||
|
var id string
|
||||||
|
var parts []string
|
||||||
|
var i int
|
||||||
|
id = NormalizeServerIdentifier(value)
|
||||||
|
parts = strings.Split(id, "-")
|
||||||
|
for i = 0; i < len(parts); i++ {
|
||||||
|
parts[i] = titleWord(parts[i])
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleWord(value string) string {
|
||||||
|
var runes []rune
|
||||||
|
var i int
|
||||||
|
runes = []rune(strings.ToLower(strings.TrimSpace(value)))
|
||||||
|
if len(runes) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes[0] = unicode.ToTitle(runes[0])
|
||||||
|
for i = 1; i < len(runes); i++ {
|
||||||
|
runes[i] = unicode.ToLower(runes[i])
|
||||||
|
}
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveListeners(ctx context.Context, endpoints []listenerEndpoint, handler http.Handler, logger Logger, serverIdentifier string) error {
|
||||||
var listenCtx context.Context
|
var listenCtx context.Context
|
||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@@ -1943,6 +2005,7 @@ func serveListeners(ctx context.Context, endpoints []listenerEndpoint, handler h
|
|||||||
var ep listenerEndpoint
|
var ep listenerEndpoint
|
||||||
var listenErr error
|
var listenErr error
|
||||||
var shutdown func()
|
var shutdown func()
|
||||||
|
serverIdentifier = NormalizeServerIdentifier(serverIdentifier)
|
||||||
if len(endpoints) == 0 {
|
if len(endpoints) == 0 {
|
||||||
return http.ErrServerClosed
|
return http.ErrServerClosed
|
||||||
}
|
}
|
||||||
@@ -1967,9 +2030,9 @@ func serveListeners(ctx context.Context, endpoints []listenerEndpoint, handler h
|
|||||||
for i = 0; i < len(endpoints); i++ {
|
for i = 0; i < len(endpoints); i++ {
|
||||||
ep = endpoints[i]
|
ep = endpoints[i]
|
||||||
if ep.IsHTTPS {
|
if ep.IsHTTPS {
|
||||||
logger.Write("", LOG_INFO, "codit server listener=%s https://%s", ep.Name, ep.Addr)
|
logger.Write("", LOG_INFO, "%s server listener=%s https://%s", serverIdentifier, ep.Name, ep.Addr)
|
||||||
} else {
|
} else {
|
||||||
logger.Write("", LOG_INFO, "codit server listener=%s %s", ep.Name, ep.Addr)
|
logger.Write("", LOG_INFO, "%s server listener=%s %s", serverIdentifier, ep.Name, ep.Addr)
|
||||||
}
|
}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(endpoint listenerEndpoint) {
|
go func(endpoint listenerEndpoint) {
|
||||||
@@ -2150,7 +2213,7 @@ func parseTLSMinVersion(value string) uint16 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeTLSSettingsFromDB(cfg *codit_config.Config, store *db.Store) error {
|
func mergeTLSSettingsFromDB(cfg *codit_config.Config, store *db.Store, envPrefix string) error {
|
||||||
var settings models.TLSSettings
|
var settings models.TLSSettings
|
||||||
var envHTTPAddrs string
|
var envHTTPAddrs string
|
||||||
var envHTTPSAddrs string
|
var envHTTPSAddrs string
|
||||||
@@ -2167,16 +2230,16 @@ func mergeTLSSettingsFromDB(cfg *codit_config.Config, store *db.Store) error {
|
|||||||
settings, err = store.GetTLSSettings()
|
settings, err = store.GetTLSSettings()
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
|
|
||||||
envHTTPAddrs = strings.TrimSpace(os.Getenv("CODIT_HTTP_ADDRS"))
|
envHTTPAddrs = strings.TrimSpace(os.Getenv(envPrefix + "HTTP_ADDRS"))
|
||||||
envHTTPSAddrs = strings.TrimSpace(os.Getenv("CODIT_HTTPS_ADDRS"))
|
envHTTPSAddrs = strings.TrimSpace(os.Getenv(envPrefix + "HTTPS_ADDRS"))
|
||||||
envServerSource = strings.TrimSpace(os.Getenv("CODIT_TLS_SERVER_CERT_SOURCE"))
|
envServerSource = strings.TrimSpace(os.Getenv(envPrefix + "TLS_SERVER_CERT_SOURCE"))
|
||||||
envCertFile = strings.TrimSpace(os.Getenv("CODIT_TLS_CERT_FILE"))
|
envCertFile = strings.TrimSpace(os.Getenv(envPrefix + "TLS_CERT_FILE"))
|
||||||
envKeyFile = strings.TrimSpace(os.Getenv("CODIT_TLS_KEY_FILE"))
|
envKeyFile = strings.TrimSpace(os.Getenv(envPrefix + "TLS_KEY_FILE"))
|
||||||
envPKIServerCertID = strings.TrimSpace(os.Getenv("CODIT_TLS_PKI_SERVER_CERT_ID"))
|
envPKIServerCertID = strings.TrimSpace(os.Getenv(envPrefix + "TLS_PKI_SERVER_CERT_ID"))
|
||||||
envClientAuth = strings.TrimSpace(os.Getenv("CODIT_TLS_CLIENT_AUTH"))
|
envClientAuth = strings.TrimSpace(os.Getenv(envPrefix + "TLS_CLIENT_AUTH"))
|
||||||
envClientCAFile = strings.TrimSpace(os.Getenv("CODIT_TLS_CLIENT_CA_FILE"))
|
envClientCAFile = strings.TrimSpace(os.Getenv(envPrefix + "TLS_CLIENT_CA_FILE"))
|
||||||
envPKIClientCAID = strings.TrimSpace(os.Getenv("CODIT_TLS_PKI_CLIENT_CA_ID"))
|
envPKIClientCAID = strings.TrimSpace(os.Getenv(envPrefix + "TLS_PKI_CLIENT_CA_ID"))
|
||||||
envTLSMinVersion = strings.TrimSpace(os.Getenv("CODIT_TLS_MIN_VERSION"))
|
envTLSMinVersion = strings.TrimSpace(os.Getenv(envPrefix + "TLS_MIN_VERSION"))
|
||||||
if len(settings.HTTPAddrs) > 0 && envHTTPAddrs == "" {
|
if len(settings.HTTPAddrs) > 0 && envHTTPAddrs == "" {
|
||||||
cfg.HTTPAddrs = settings.HTTPAddrs
|
cfg.HTTPAddrs = settings.HTTPAddrs
|
||||||
}
|
}
|
||||||
@@ -2247,7 +2310,7 @@ func spaHandler(root string) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func bootstrapAdmin(store *db.Store) error {
|
func bootstrapAdmin(store *db.Store, envPrefix string) error {
|
||||||
var txStore *db.Store
|
var txStore *db.Store
|
||||||
var bootstrap string
|
var bootstrap string
|
||||||
var parts []string
|
var parts []string
|
||||||
@@ -2256,7 +2319,7 @@ func bootstrapAdmin(store *db.Store) error {
|
|||||||
var hash string
|
var hash string
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
bootstrap = os.Getenv("CODIT_BOOTSTRAP_ADMIN")
|
bootstrap = os.Getenv(envPrefix + "BOOTSTRAP_ADMIN")
|
||||||
if bootstrap == "" {
|
if bootstrap == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Codit</title>
|
<title>%VITE_APP_TITLE%</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+10
@@ -16,6 +16,7 @@
|
|||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
@@ -3433,6 +3434,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
|
|||||||
+19
-2
@@ -5,10 +5,20 @@ export interface User {
|
|||||||
email: string
|
email: string
|
||||||
is_admin: boolean
|
is_admin: boolean
|
||||||
auth_source?: string
|
auth_source?: string
|
||||||
|
totp_enabled?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
permissions?: string[]
|
permissions?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TOTPStatus = {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TOTPSetupResponse = {
|
||||||
|
secret: string
|
||||||
|
otpauth_url: string
|
||||||
|
}
|
||||||
|
|
||||||
export type UserCreatePayload = {
|
export type UserCreatePayload = {
|
||||||
username: string
|
username: string
|
||||||
display_name: string
|
display_name: string
|
||||||
@@ -1117,15 +1127,21 @@ async function requestText(path: string, options: RequestInit = {}): Promise<str
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
oidcStatus: () => request<OIDCStatus>('/api/auth/oidc/enabled'),
|
oidcStatus: () => request<OIDCStatus>('/api/auth/oidc/enabled'),
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string, otpCode?: string) =>
|
||||||
request<User>('/api/login', {
|
request<User>('/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password, otp_code: otpCode || '' })
|
||||||
}),
|
}),
|
||||||
logout: () => request<void>('/api/logout', { method: 'POST' }),
|
logout: () => request<void>('/api/logout', { method: 'POST' }),
|
||||||
me: () => request<User>('/api/me'),
|
me: () => request<User>('/api/me'),
|
||||||
updateMe: (payload: MeUpdatePayload) =>
|
updateMe: (payload: MeUpdatePayload) =>
|
||||||
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
|
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||||
|
getMyTOTP: () => request<TOTPStatus>('/api/me/totp'),
|
||||||
|
setupMyTOTP: () => request<TOTPSetupResponse>('/api/me/totp/setup', { method: 'POST' }),
|
||||||
|
enableMyTOTP: (otpCode: string) =>
|
||||||
|
request<TOTPStatus>('/api/me/totp/enable', { method: 'POST', body: JSON.stringify({ otp_code: otpCode }) }),
|
||||||
|
disableMyTOTP: (password: string, otpCode: string) =>
|
||||||
|
request<TOTPStatus>('/api/me/totp/disable', { method: 'POST', body: JSON.stringify({ password, otp_code: otpCode }) }),
|
||||||
listAPIKeys: () => request<APIKey[]>('/api/me/keys'),
|
listAPIKeys: () => request<APIKey[]>('/api/me/keys'),
|
||||||
createAPIKey: (name: string, expires_at?: number) =>
|
createAPIKey: (name: string, expires_at?: number) =>
|
||||||
request<APIKeyWithToken>('/api/me/keys', { method: 'POST', body: JSON.stringify({ name, expires_at: expires_at || 0 }) }),
|
request<APIKeyWithToken>('/api/me/keys', { method: 'POST', body: JSON.stringify({ name, expires_at: expires_at || 0 }) }),
|
||||||
@@ -1396,6 +1412,7 @@ export const api = {
|
|||||||
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
|
||||||
disableUser: (id: string) => request<void>(`/api/users/${id}/disable`, { method: 'POST' }),
|
disableUser: (id: string) => request<void>(`/api/users/${id}/disable`, { method: 'POST' }),
|
||||||
enableUser: (id: string) => request<void>(`/api/users/${id}/enable`, { method: 'POST' }),
|
enableUser: (id: string) => request<void>(`/api/users/${id}/enable`, { method: 'POST' }),
|
||||||
|
resetUserTOTP: (id: string) => request<void>(`/api/users/${id}/totp/reset`, { method: 'POST' }),
|
||||||
listUserGroups: () => request<UserGroup[]>('/api/admin/user-groups'),
|
listUserGroups: () => request<UserGroup[]>('/api/admin/user-groups'),
|
||||||
createUserGroup: (payload: UserGroupUpsertPayload) =>
|
createUserGroup: (payload: UserGroupUpsertPayload) =>
|
||||||
request<UserGroup>('/api/admin/user-groups', { method: 'POST', body: JSON.stringify(payload) }),
|
request<UserGroup>('/api/admin/user-groups', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ import { useRoutes } from 'react-router-dom'
|
|||||||
import { routes } from './routes'
|
import { routes } from './routes'
|
||||||
import { ThemeModeContext } from './ThemeModeContext'
|
import { ThemeModeContext } from './ThemeModeContext'
|
||||||
import { createAppTheme, isThemeScheme, ThemeScheme } from './theme'
|
import { createAppTheme, isThemeScheme, ThemeScheme } from './theme'
|
||||||
|
import { storageKey } from '../branding'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const element = useRoutes(routes)
|
const element = useRoutes(routes)
|
||||||
const [mode, setMode] = useState<'light' | 'dark'>(() => {
|
const [mode, setMode] = useState<'light' | 'dark'>(() => {
|
||||||
const stored = localStorage.getItem('codit-theme-mode') || localStorage.getItem('codit-theme')
|
const stored = localStorage.getItem(storageKey('theme-mode'))
|
||||||
return stored === 'dark' ? 'dark' : 'light'
|
return stored === 'dark' ? 'dark' : 'light'
|
||||||
})
|
})
|
||||||
const [scheme, setScheme] = useState<ThemeScheme>(() => {
|
const [scheme, setScheme] = useState<ThemeScheme>(() => {
|
||||||
const stored = localStorage.getItem('codit-theme-scheme')
|
const stored = localStorage.getItem(storageKey('theme-scheme'))
|
||||||
return isThemeScheme(stored) ? stored : 'blue'
|
return isThemeScheme(stored) ? stored : 'blue'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -44,8 +45,7 @@ export default function App() {
|
|||||||
}, [mode])
|
}, [mode])
|
||||||
|
|
||||||
const updateMode = (next: 'light' | 'dark') => {
|
const updateMode = (next: 'light' | 'dark') => {
|
||||||
localStorage.setItem('codit-theme-mode', next)
|
localStorage.setItem(storageKey('theme-mode'), next)
|
||||||
localStorage.setItem('codit-theme', next)
|
|
||||||
setMode(next)
|
setMode(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateScheme = (next: ThemeScheme) => {
|
const updateScheme = (next: ThemeScheme) => {
|
||||||
localStorage.setItem('codit-theme-scheme', next)
|
localStorage.setItem(storageKey('theme-scheme'), next)
|
||||||
setScheme(next)
|
setScheme(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { api, User } from '../api'
|
import { api, User } from '../api'
|
||||||
|
import { appBrand } from '../branding'
|
||||||
import MenuIcon from '@mui/icons-material/Menu'
|
import MenuIcon from '@mui/icons-material/Menu'
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||||
import WorkspacesIcon from '@mui/icons-material/Workspaces'
|
import WorkspacesIcon from '@mui/icons-material/Workspaces'
|
||||||
@@ -161,7 +162,7 @@ export default function Layout() {
|
|||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||||
Codit
|
{appBrand.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button color="inherit" onClick={openAppearanceMenu} startIcon={<PaletteIcon />} endIcon={<KeyboardArrowDownIcon />}>
|
<Button color="inherit" onClick={openAppearanceMenu} startIcon={<PaletteIcon />} endIcon={<KeyboardArrowDownIcon />}>
|
||||||
Appearance
|
Appearance
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const appBrand = {
|
||||||
|
identifier: import.meta.env.VITE_APP_IDENTIFIER || 'codit',
|
||||||
|
title: import.meta.env.VITE_APP_TITLE || 'Codit',
|
||||||
|
storagePrefix: import.meta.env.VITE_APP_IDENTIFIER || 'codit',
|
||||||
|
sanURIPrefix: `urn:${import.meta.env.VITE_APP_IDENTIFIER || 'codit'}:user:`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storageKey(name: string): string {
|
||||||
|
return `${appBrand.storagePrefix}-${name}`
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import TextField from '@mui/material/TextField'
|
|||||||
import Autocomplete from './Autocomplete'
|
import Autocomplete from './Autocomplete'
|
||||||
import FormDialogContent from './FormDialogContent'
|
import FormDialogContent from './FormDialogContent'
|
||||||
import SelectField from './SelectField'
|
import SelectField from './SelectField'
|
||||||
|
import { appBrand } from '../branding'
|
||||||
import { PKICA } from '../api'
|
import { PKICA } from '../api'
|
||||||
|
|
||||||
type PKIClientProfileFormOption = {
|
type PKIClientProfileFormOption = {
|
||||||
@@ -69,7 +70,7 @@ export default function PKIClientProfileFormDialog(props: PKIClientProfileFormDi
|
|||||||
label="SAN URI Prefix"
|
label="SAN URI Prefix"
|
||||||
value={props.form.sanURIPrefix}
|
value={props.form.sanURIPrefix}
|
||||||
onChange={(event) => props.setForm((prev) => ({ ...prev, sanURIPrefix: event.target.value }))}
|
onChange={(event) => props.setForm((prev) => ({ ...prev, sanURIPrefix: event.target.value }))}
|
||||||
helperText="The user id is appended to this prefix. Example: urn:codit:user:"
|
helperText={`The user id is appended to this prefix. Example: ${appBrand.sanURIPrefix}`}
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Checkbox checked={props.form.allowServerAuth} onChange={(event) => props.setForm((prev) => ({ ...prev, allowServerAuth: event.target.checked }))} />}
|
control={<Checkbox checked={props.form.allowServerAuth} onChange={(event) => props.setForm((prev) => ({ ...prev, allowServerAuth: event.target.checked }))} />}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import { Box, Button, Paper, TextField, Typography } from '@mui/material'
|
import { Box, Button, Paper, TextField, Typography } from '@mui/material'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { api, User } from '../api'
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { api, TOTPSetupResponse, User } from '../api'
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
@@ -12,6 +13,12 @@ export default function AccountPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [totpEnabled, setTOTPEnabled] = useState(false)
|
||||||
|
const [totpSetup, setTOTPSetup] = useState<TOTPSetupResponse | null>(null)
|
||||||
|
const [totpCode, setTOTPCode] = useState('')
|
||||||
|
const [totpPassword, setTOTPPassword] = useState('')
|
||||||
|
const [totpBusy, setTOTPBusy] = useState(false)
|
||||||
|
const [totpError, setTOTPError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
@@ -20,10 +27,19 @@ export default function AccountPage() {
|
|||||||
setUser(me)
|
setUser(me)
|
||||||
setDisplayName(me.display_name || '')
|
setDisplayName(me.display_name || '')
|
||||||
setEmail(me.email || '')
|
setEmail(me.email || '')
|
||||||
|
setTOTPEnabled(Boolean(me.totp_enabled))
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
})
|
})
|
||||||
|
api
|
||||||
|
.getMyTOTP()
|
||||||
|
.then((status) => {
|
||||||
|
setTOTPEnabled(Boolean(status.enabled))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setTOTPEnabled(false)
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -54,6 +70,58 @@ export default function AccountPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSetupTOTP = async () => {
|
||||||
|
let setup: TOTPSetupResponse
|
||||||
|
let message: string
|
||||||
|
setTOTPBusy(true)
|
||||||
|
setTOTPError(null)
|
||||||
|
try {
|
||||||
|
setup = await api.setupMyTOTP()
|
||||||
|
setTOTPSetup(setup)
|
||||||
|
setTOTPCode('')
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'Failed to start TOTP setup'
|
||||||
|
setTOTPError(message)
|
||||||
|
} finally {
|
||||||
|
setTOTPBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnableTOTP = async () => {
|
||||||
|
let message: string
|
||||||
|
setTOTPBusy(true)
|
||||||
|
setTOTPError(null)
|
||||||
|
try {
|
||||||
|
await api.enableMyTOTP(totpCode.trim())
|
||||||
|
setTOTPEnabled(true)
|
||||||
|
setTOTPSetup(null)
|
||||||
|
setTOTPCode('')
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'Failed to enable TOTP'
|
||||||
|
setTOTPError(message)
|
||||||
|
} finally {
|
||||||
|
setTOTPBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisableTOTP = async () => {
|
||||||
|
let message: string
|
||||||
|
setTOTPBusy(true)
|
||||||
|
setTOTPError(null)
|
||||||
|
try {
|
||||||
|
await api.disableMyTOTP(totpPassword, totpCode.trim())
|
||||||
|
setTOTPEnabled(false)
|
||||||
|
setTOTPSetup(null)
|
||||||
|
setTOTPPassword('')
|
||||||
|
setTOTPCode('')
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'Failed to disable TOTP'
|
||||||
|
setTOTPError(message)
|
||||||
|
} finally {
|
||||||
|
setTOTPBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||||
@@ -96,6 +164,61 @@ export default function AccountPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
<Paper sx={{ p: 2, maxWidth: 560, mt: 2, backgroundColor: 'transparent' }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
|
Authenticator App
|
||||||
|
</Typography>
|
||||||
|
{totpError ? <Alert severity="error" sx={{ mb: 1 }}>{totpError}</Alert> : null}
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Status: {totpEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Typography>
|
||||||
|
{!totpEnabled ? (
|
||||||
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
|
<Button variant="outlined" onClick={handleSetupTOTP} disabled={totpBusy}>
|
||||||
|
Start TOTP Setup
|
||||||
|
</Button>
|
||||||
|
{totpSetup ? (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2, backgroundColor: '#fff', borderRadius: 1 }}>
|
||||||
|
<QRCodeSVG value={totpSetup.otpauth_url} size={192} />
|
||||||
|
</Box>
|
||||||
|
<TextField label="Secret" value={totpSetup.secret} InputProps={{ readOnly: true }} />
|
||||||
|
<TextField label="otpauth URL" value={totpSetup.otpauth_url} InputProps={{ readOnly: true }} multiline />
|
||||||
|
<TextField
|
||||||
|
label="Authenticator Code"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(event) => setTOTPCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button variant="contained" onClick={handleEnableTOTP} disabled={totpBusy || totpCode.trim() === ''}>
|
||||||
|
Enable TOTP
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Current Password"
|
||||||
|
type="password"
|
||||||
|
value={totpPassword}
|
||||||
|
onChange={(event) => setTOTPPassword(event.target.value)}
|
||||||
|
helperText={user?.auth_source === 'db' ? 'Required for db users.' : 'Non-db users can leave this empty.'}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Authenticator Code"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(event) => setTOTPCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button color="warning" variant="outlined" onClick={handleDisableTOTP} disabled={totpBusy || totpCode.trim() === ''}>
|
||||||
|
Disable TOTP
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import PKIClientProfileDetailsDialog from '../components/PKIClientProfileDetails
|
|||||||
import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api'
|
import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api'
|
||||||
|
import { appBrand } from '../branding'
|
||||||
|
|
||||||
function fmt(value: number): string {
|
function fmt(value: number): string {
|
||||||
if (!value || value <= 0) {
|
if (!value || value <= 0) {
|
||||||
@@ -50,8 +51,8 @@ function defaultFormState(defaultCAID: string): PKIClientProfileFormState {
|
|||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
caID: defaultCAID,
|
caID: defaultCAID,
|
||||||
subjectOrganization: 'Codit',
|
subjectOrganization: appBrand.title,
|
||||||
sanURIPrefix: 'urn:codit:user:',
|
sanURIPrefix: appBrand.sanURIPrefix,
|
||||||
allowServerAuth: false,
|
allowServerAuth: false,
|
||||||
permissionsText: 'read',
|
permissionsText: 'read',
|
||||||
authzScope: '',
|
authzScope: '',
|
||||||
@@ -139,8 +140,8 @@ export default function AdminPKIClientProfilesPage() {
|
|||||||
setForm({
|
setForm({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
caID: item.ca_id,
|
caID: item.ca_id,
|
||||||
subjectOrganization: item.subject_organization || 'Codit',
|
subjectOrganization: item.subject_organization || appBrand.title,
|
||||||
sanURIPrefix: item.san_uri_prefix || 'urn:codit:user:',
|
sanURIPrefix: item.san_uri_prefix || appBrand.sanURIPrefix,
|
||||||
allowServerAuth: Boolean(item.allow_server_auth),
|
allowServerAuth: Boolean(item.allow_server_auth),
|
||||||
permissionsText: (item.authz_permissions || []).join(', '),
|
permissionsText: (item.authz_permissions || []).join(', '),
|
||||||
authzScope: item.authz_scope || '',
|
authzScope: item.authz_scope || '',
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
TextField,
|
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -52,6 +45,9 @@ export default function AdminUsersPage() {
|
|||||||
const [editForm, setEditForm] = useState<UserFormState>(createEmptyForm)
|
const [editForm, setEditForm] = useState<UserFormState>(createEmptyForm)
|
||||||
const [editError, setEditError] = useState<string | null>(null)
|
const [editError, setEditError] = useState<string | null>(null)
|
||||||
const [savingEdit, setSavingEdit] = useState(false)
|
const [savingEdit, setSavingEdit] = useState(false)
|
||||||
|
const [resetTOTPUser, setResetTOTPUser] = useState<User | null>(null)
|
||||||
|
const [resetTOTPConfirm, setResetTOTPConfirm] = useState('')
|
||||||
|
const [resettingTOTP, setResettingTOTP] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reloadUsers().catch((err) => {
|
reloadUsers().catch((err) => {
|
||||||
@@ -164,6 +160,26 @@ export default function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleResetTOTP = async () => {
|
||||||
|
let message: string
|
||||||
|
if (!resetTOTPUser) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResettingTOTP(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.resetUserTOTP(resetTOTPUser.id)
|
||||||
|
setResetTOTPUser(null)
|
||||||
|
setResetTOTPConfirm('')
|
||||||
|
await reloadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'Failed to reset TOTP'
|
||||||
|
setError(message)
|
||||||
|
} finally {
|
||||||
|
setResettingTOTP(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openEditUser = (user: User) => {
|
const openEditUser = (user: User) => {
|
||||||
setEditUser(user)
|
setEditUser(user)
|
||||||
setEditForm({
|
setEditForm({
|
||||||
@@ -249,7 +265,7 @@ export default function AdminUsersPage() {
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={`${u.display_name || u.username} (${u.username})${u.disabled ? ' (disabled)' : ''}`}
|
primary={`${u.display_name || u.username} (${u.username})${u.disabled ? ' (disabled)' : ''}`}
|
||||||
secondary={`${u.is_admin ? 'admin' : 'user'} · source: ${u.auth_source || 'db'}${hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}`}
|
secondary={`${u.is_admin ? 'admin' : 'user'} · source: ${u.auth_source || 'db'}${u.totp_enabled ? ' · TOTP enabled' : ''}${hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}`}
|
||||||
sx={{ minWidth: 0, m: 0 }}
|
sx={{ minWidth: 0, m: 0 }}
|
||||||
/>
|
/>
|
||||||
<ListRowActions>
|
<ListRowActions>
|
||||||
@@ -263,6 +279,13 @@ export default function AdminUsersPage() {
|
|||||||
>
|
>
|
||||||
{u.disabled ? 'Enable' : 'Disable'}
|
{u.disabled ? 'Enable' : 'Disable'}
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
|
<ListRowActionButton
|
||||||
|
color="warning"
|
||||||
|
onClick={() => setResetTOTPUser(u)}
|
||||||
|
disabled={!u.totp_enabled}
|
||||||
|
>
|
||||||
|
Reset TOTP
|
||||||
|
</ListRowActionButton>
|
||||||
<ListRowActionButton color="error" onClick={() => setDeletingUser(u)}>
|
<ListRowActionButton color="error" onClick={() => setDeletingUser(u)}>
|
||||||
Delete
|
Delete
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
@@ -305,6 +328,19 @@ export default function AdminUsersPage() {
|
|||||||
onConfirm={handleDeleteUser}
|
onConfirm={handleDeleteUser}
|
||||||
onClose={() => { setDeletingUser(null); setDeleteConfirm('') }}
|
onClose={() => { setDeletingUser(null); setDeleteConfirm('') }}
|
||||||
/>
|
/>
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={Boolean(resetTOTPUser)}
|
||||||
|
title="Reset TOTP"
|
||||||
|
prompt="Type the username to confirm resetting this user's TOTP."
|
||||||
|
expectedText={resetTOTPUser?.username}
|
||||||
|
confirmLabel="Username"
|
||||||
|
confirmValue={resetTOTPConfirm}
|
||||||
|
onConfirmValueChange={setResetTOTPConfirm}
|
||||||
|
busy={resettingTOTP}
|
||||||
|
confirmDisabled={!resetTOTPUser}
|
||||||
|
onConfirm={handleResetTOTP}
|
||||||
|
onClose={() => { setResetTOTPUser(null); setResetTOTPConfirm('') }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import { Box, Button, Paper, TextField, Typography } from '@mui/material'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { appBrand } from '../branding'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [oidcEnabled, setOIDCEnabled] = useState(false)
|
const [oidcEnabled, setOIDCEnabled] = useState(false)
|
||||||
const [oidcConfigured, setOIDCConfigured] = useState(true)
|
const [oidcConfigured, setOIDCConfigured] = useState(true)
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [otpCode, setOTPCode] = useState('')
|
||||||
|
const [otpRequired, setOTPRequired] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,33 +28,50 @@ export default function LoginPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleLogin = async (evt: React.FormEvent<HTMLFormElement>) => {
|
const handleLogin = async (evt: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
let message: string
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
const data = new FormData(evt.currentTarget)
|
setError(null)
|
||||||
const username = String(data.get('username') || '')
|
|
||||||
const password = String(data.get('password') || '')
|
|
||||||
try {
|
try {
|
||||||
await api.login(username, password)
|
await api.login(username, password, otpRequired ? otpCode : '')
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message)
|
message = err instanceof Error ? err.message : 'Login failed'
|
||||||
|
if (message === 'totp_required') {
|
||||||
|
setOTPRequired(true)
|
||||||
|
setOTPCode('')
|
||||||
|
setError('Enter your authenticator code.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 4, maxWidth: 420, mx: 'auto', mt: 12, backgroundColor: 'transparent' }}>
|
<Paper sx={{ p: 4, maxWidth: 420, mx: 'auto', mt: 12, backgroundColor: 'transparent' }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Sign in to Codit
|
Sign in to {appBrand.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="form" onSubmit={handleLogin}>
|
<Box component="form" onSubmit={handleLogin}>
|
||||||
<TextField name="username" label="Username" fullWidth margin="normal" />
|
<TextField name="username" label="Username" fullWidth margin="normal" value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||||
<TextField name="password" label="Password" type="password" fullWidth margin="normal" />
|
<TextField name="password" label="Password" type="password" fullWidth margin="normal" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||||
|
{otpRequired ? (
|
||||||
|
<TextField
|
||||||
|
name="otp_code"
|
||||||
|
label="Authenticator Code"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(event) => setOTPCode(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{error && (
|
{error && (
|
||||||
<Typography color="error" variant="body2">
|
<Typography color="error" variant="body2">
|
||||||
{error}
|
{error}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
|
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
|
||||||
Login
|
{otpRequired ? 'Verify' : 'Login'}
|
||||||
</Button>
|
</Button>
|
||||||
{oidcEnabled ? (
|
{oidcEnabled ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user