Compare commits

..

3 Commits

24 changed files with 626 additions and 118 deletions
+1 -1
View File
@@ -172,7 +172,7 @@ func loadConfigFiles(cfg *Config, paths []string) error {
path = expanded[i] path = expanded[i]
err = loadConfigFile(cfg, path) err = loadConfigFile(cfg, path)
if err != nil { if err != nil {
return fmt.Errorf("load config file %s: %w", path, err) return fmt.Errorf("load config file %s - %w", path, err)
} }
} }
return nil return nil
+79 -17
View File
@@ -8,18 +8,31 @@ import "time"
import "codit/internal/models" import "codit/internal/models"
import "codit/internal/util" import "codit/internal/util"
const UserGroupScopeExplicit string = "explicit"
const UserGroupScopeAllUsers string = "all_users"
func NormalizeUserGroupScope(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case UserGroupScopeAllUsers:
return UserGroupScopeAllUsers
default:
return UserGroupScopeExplicit
}
}
func (s *Store) ListUserGroups() ([]models.UserGroup, error) { func (s *Store) ListUserGroups() ([]models.UserGroup, error) {
var rows *sql.Rows var rows *sql.Rows
var err error var err error
var items []models.UserGroup var items []models.UserGroup
var item models.UserGroup var item models.UserGroup
rows, err = s.Query(`SELECT public_id, name, description, disabled, created_at, updated_at FROM user_groups ORDER BY name`) rows, err = s.Query(`SELECT public_id, name, description, disabled, totp_required, scope, created_at, updated_at FROM user_groups ORDER BY name`)
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(&item.ID, &item.Name, &item.Description, &item.Disabled, &item.CreatedAt, &item.UpdatedAt) err = rows.Scan(&item.ID, &item.Name, &item.Description, &item.Disabled, &item.TOTPRequired, &item.Scope, &item.CreatedAt, &item.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -36,9 +49,9 @@ func (s *Store) GetUserGroup(groupID string) (models.UserGroup, error) {
var row *sql.Row var row *sql.Row
var item models.UserGroup var item models.UserGroup
var err error var err error
row = s.QueryRow(`SELECT public_id, name, description, disabled, created_at, updated_at row = s.QueryRow(`SELECT public_id, name, description, disabled, totp_required, scope, created_at, updated_at
FROM user_groups WHERE public_id = ?`, strings.TrimSpace(groupID)) FROM user_groups WHERE public_id = ?`, strings.TrimSpace(groupID))
err = row.Scan(&item.ID, &item.Name, &item.Description, &item.Disabled, &item.CreatedAt, &item.UpdatedAt) err = row.Scan(&item.ID, &item.Name, &item.Description, &item.Disabled, &item.TOTPRequired, &item.Scope, &item.CreatedAt, &item.UpdatedAt)
return item, err return item, err
} }
@@ -56,12 +69,15 @@ func (s *Store) CreateUserGroup(item models.UserGroup) (models.UserGroup, error)
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
item.CreatedAt = now item.CreatedAt = now
item.UpdatedAt = now item.UpdatedAt = now
_, err = s.Exec(`INSERT INTO user_groups (public_id, name, description, disabled, created_at, updated_at) item.Scope = NormalizeUserGroupScope(item.Scope)
VALUES (?, ?, ?, ?, ?, ?)`, _, err = s.Exec(`INSERT INTO user_groups (public_id, name, description, disabled, totp_required, scope, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.ID,
strings.TrimSpace(item.Name), strings.TrimSpace(item.Name),
strings.TrimSpace(item.Description), strings.TrimSpace(item.Description),
item.Disabled, item.Disabled,
item.TOTPRequired,
item.Scope,
item.CreatedAt, item.CreatedAt,
item.UpdatedAt, item.UpdatedAt,
) )
@@ -76,12 +92,15 @@ func (s *Store) UpdateUserGroup(item models.UserGroup) (models.UserGroup, error)
var err error var err error
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
item.UpdatedAt = now item.UpdatedAt = now
item.Scope = NormalizeUserGroupScope(item.Scope)
_, err = s.Exec(`UPDATE user_groups _, err = s.Exec(`UPDATE user_groups
SET name = ?, description = ?, disabled = ?, updated_at = ? SET name = ?, description = ?, disabled = ?, totp_required = ?, scope = ?, updated_at = ?
WHERE public_id = ?`, WHERE public_id = ?`,
strings.TrimSpace(item.Name), strings.TrimSpace(item.Name),
strings.TrimSpace(item.Description), strings.TrimSpace(item.Description),
item.Disabled, item.Disabled,
item.TOTPRequired,
item.Scope,
item.UpdatedAt, item.UpdatedAt,
strings.TrimSpace(item.ID), strings.TrimSpace(item.ID),
) )
@@ -99,6 +118,14 @@ func (s *Store) DeleteUserGroup(groupID string) error {
var tx *sql.Tx var tx *sql.Tx
var owned bool var owned bool
var err error var err error
var group models.UserGroup
group, err = s.GetUserGroup(groupID)
if err != nil {
return err
}
if NormalizeUserGroupScope(group.Scope) == UserGroupScopeAllUsers {
return errors.New("all users group cannot be deleted")
}
tx, owned, err = s.begin() tx, owned, err = s.begin()
if err != nil { if err != nil {
return err return err
@@ -135,12 +162,24 @@ func (s *Store) ListUserGroupMembers(groupID string) ([]models.UserGroupMember,
var err error var err error
var items []models.UserGroupMember var items []models.UserGroupMember
var item models.UserGroupMember var item models.UserGroupMember
rows, err = s.Query(`SELECT g.public_id, u.public_id, u.username, u.display_name, m.created_at var group models.UserGroup
FROM user_group_members m group, err = s.GetUserGroup(groupID)
JOIN user_groups g ON g.id = m.group_id if err != nil {
JOIN users u ON u.id = m.user_id return nil, err
WHERE g.public_id = ? }
ORDER BY u.username`, strings.TrimSpace(groupID)) if NormalizeUserGroupScope(group.Scope) == UserGroupScopeAllUsers {
rows, err = s.Query(`SELECT ? AS group_public_id, u.public_id, u.username, u.display_name, 0 AS created_at
FROM users u
WHERE u.disabled = 0
ORDER BY u.username`, strings.TrimSpace(groupID))
} else {
rows, err = s.Query(`SELECT g.public_id, u.public_id, u.username, u.display_name, m.created_at
FROM user_group_members m
JOIN user_groups g ON g.id = m.group_id
JOIN users u ON u.id = m.user_id
WHERE g.public_id = ?
ORDER BY u.username`, strings.TrimSpace(groupID))
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -162,6 +201,14 @@ func (s *Store) ListUserGroupMembers(groupID string) ([]models.UserGroupMember,
func (s *Store) AddUserGroupMember(groupID string, userID string) error { func (s *Store) AddUserGroupMember(groupID string, userID string) error {
var now int64 var now int64
var err error var err error
var group models.UserGroup
group, err = s.GetUserGroup(groupID)
if err != nil {
return err
}
if NormalizeUserGroupScope(group.Scope) != UserGroupScopeExplicit {
return errors.New("virtual groups do not accept explicit members")
}
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
_, err = s.Exec(`INSERT OR IGNORE INTO user_group_members (group_id, user_id, created_at) _, err = s.Exec(`INSERT OR IGNORE INTO user_group_members (group_id, user_id, created_at)
VALUES ((SELECT id FROM user_groups WHERE public_id = ?), (SELECT id FROM users WHERE public_id = ?), ?)`, VALUES ((SELECT id FROM user_groups WHERE public_id = ?), (SELECT id FROM users WHERE public_id = ?), ?)`,
@@ -171,6 +218,14 @@ func (s *Store) AddUserGroupMember(groupID string, userID string) error {
func (s *Store) RemoveUserGroupMember(groupID string, userID string) error { func (s *Store) RemoveUserGroupMember(groupID string, userID string) error {
var err error var err error
var group models.UserGroup
group, err = s.GetUserGroup(groupID)
if err != nil {
return err
}
if NormalizeUserGroupScope(group.Scope) != UserGroupScopeExplicit {
return errors.New("virtual groups do not have explicit members")
}
_, err = s.Exec(`DELETE FROM user_group_members _, err = s.Exec(`DELETE FROM user_group_members
WHERE group_id = (SELECT id FROM user_groups WHERE public_id = ?) WHERE group_id = (SELECT id FROM user_groups WHERE public_id = ?)
AND user_id = (SELECT id FROM users WHERE public_id = ?)`, AND user_id = (SELECT id FROM users WHERE public_id = ?)`,
@@ -264,10 +319,17 @@ func (s *Store) GetProjectRoleForUser(projectID string, userID string) (string,
OR OR
(b.subject_type = 'group' AND b.subject_public_id IN ( (b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id SELECT g.public_id
FROM user_group_members gm FROM user_groups g
JOIN users ux ON ux.id = gm.user_id WHERE g.disabled = 0
JOIN user_groups g ON g.id = gm.group_id AND (
WHERE ux.public_id = ? AND g.disabled = 0 g.scope = 'all_users'
OR EXISTS (
SELECT 1
FROM user_group_members gm
JOIN users ux ON ux.id = gm.user_id
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
)) ))
)`, )`,
strings.TrimSpace(projectID), strings.TrimSpace(projectID),
+2 -2
View File
@@ -138,7 +138,7 @@ func (s *Store) ListActivePKIClientProfilesForUser(userID string) ([]models.PKIC
AND ( AND (
(t.target_type = 'user' AND t.target_public_id = ?) (t.target_type = 'user' AND t.target_public_id = ?)
OR OR
(t.target_type = 'group' AND ug.disabled = 0 AND gu.public_id = ?) (t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?))
) )
ORDER BY p.name`, ORDER BY p.name`,
strings.TrimSpace(userID), strings.TrimSpace(userID),
@@ -273,7 +273,7 @@ func (s *Store) GetActivePKIClientProfileForUser(profileID string, userID string
AND ( AND (
(t.target_type = 'user' AND t.target_public_id = ?) (t.target_type = 'user' AND t.target_public_id = ?)
OR OR
(t.target_type = 'group' AND ug.disabled = 0 AND gu.public_id = ?) (t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?))
)`, )`,
strings.TrimSpace(profileID), strings.TrimSpace(profileID),
strings.TrimSpace(userID), strings.TrimSpace(userID),
+1 -1
View File
@@ -776,7 +776,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
AND ( AND (
(t.target_type = 'user' AND t.target_public_id = ?) (t.target_type = 'user' AND t.target_public_id = ?)
OR OR
(t.target_type = 'group' AND ug.disabled = 0 AND gu.public_id = ?) (t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?))
) )
) )
) )
+1 -1
View File
@@ -130,7 +130,7 @@ func (s *Store) ListActiveSSHPrincipalGrantsForUser(userID string, now int64) ([
AND ( AND (
(t.target_type = 'user' AND t.target_public_id = ?) (t.target_type = 'user' AND t.target_public_id = ?)
OR OR
(t.target_type = 'group' AND ug.disabled = 0 AND gu.public_id = ?) (t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?))
) )
ORDER BY g.principal, g.created_at`, ORDER BY g.principal, g.created_at`,
now, now,
+104 -46
View File
@@ -28,9 +28,9 @@ func (s *Store) CreateUser(user models.User, passwordHash string) (models.User,
user.AuthSource = "db" user.AuthSource = "db"
} }
_, err = s.Exec(` _, err = s.Exec(`
INSERT INTO users (public_id, username, display_name, email, password_hash, is_admin, disabled, auth_source, created_at, updated_at) INSERT INTO users (public_id, username, display_name, email, password_hash, is_admin, disabled, auth_source, totp_required, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthSource, now, now) `, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthSource, user.TOTPRequired, now, now)
return user, err return user, err
} }
@@ -41,8 +41,8 @@ func (s *Store) UpdateUser(user models.User) error {
now = time.Now().UTC() now = time.Now().UTC()
nowUnix = now.Unix() nowUnix = now.Unix()
user.UpdatedAt = nowUnix user.UpdatedAt = nowUnix
_, err = s.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, updated_at = ? WHERE public_id = ?`, _, err = s.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, totp_required = ?, updated_at = ? WHERE public_id = ?`,
user.DisplayName, user.Email, user.IsAdmin, user.Disabled, now, user.ID) user.DisplayName, user.Email, user.IsAdmin, user.Disabled, user.TOTPRequired, now, user.ID)
return err return err
} }
@@ -59,8 +59,8 @@ func (s *Store) UpdateUserWithPassword(user models.User, passwordHash string) er
if err != nil { if err != nil {
return err return err
} }
_, err = tx.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, password_hash = ?, updated_at = ? WHERE public_id = ?`, _, err = tx.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, totp_required = ?, password_hash = ?, updated_at = ? WHERE public_id = ?`,
user.DisplayName, user.Email, user.IsAdmin, user.Disabled, passwordHash, now, user.ID) user.DisplayName, user.Email, user.IsAdmin, user.Disabled, user.TOTPRequired, passwordHash, now, user.ID)
if err != nil { if err != nil {
rollbackIfOwned(tx, owned) rollbackIfOwned(tx, owned)
return err return err
@@ -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 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) 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.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.public_id = ?`, id)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &created, &updated) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &user.TOTPRequired, &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 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) 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.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.username = ?`, username)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &user.TOTPEnabled, &created, &updated) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &user.TOTPEnabled, &user.TOTPRequired, &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 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`) 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.totp_required, 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, &u.TOTPEnabled, &created, &updated) err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthSource, &u.TOTPEnabled, &u.TOTPRequired, &created, &updated)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -846,11 +846,17 @@ func (s *Store) GetUserByAPIKeyHash(tokenHash string) (models.User, error) {
return user, nil return user, nil
} }
func (s *Store) CreateSession(userID, token string, expiresAt time.Time) error { func (s *Store) CreateSession(userID string, token string, expiresAt time.Time, totpVerified bool) error {
var err error var err error
_, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at) _, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified)
VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?)`, VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?)`,
mustID(), userID, token, expiresAt, time.Now().UTC()) mustID(), userID, token, expiresAt, time.Now().UTC(), totpVerified)
return err
}
func (s *Store) SetSessionTOTPVerified(token string) error {
var err error
_, err = s.Exec(`UPDATE sessions SET totp_verified = 1 WHERE token = ?`, token)
return err return err
} }
@@ -866,26 +872,36 @@ func (s *Store) DeleteExpiredSessions(now time.Time) error {
return err return err
} }
func (s *Store) GetSessionUser(token string) (models.User, time.Time, error) { func (s *Store) GetSessionUser(token string) (models.User, time.Time, bool, error) {
var user models.User var user models.User
var expires time.Time var expires time.Time
var totpVerified bool
var row *sql.Row var row *sql.Row
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(` 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, 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.totp_required,
(u.totp_required OR EXISTS (
SELECT 1 FROM user_group_members m JOIN user_groups g ON g.id = m.group_id
WHERE m.user_id = u.id AND g.disabled = 0 AND g.totp_required = 1
) OR EXISTS (
SELECT 1 FROM user_groups g
WHERE g.disabled = 0 AND g.totp_required = 1 AND g.scope = 'all_users'
)),
s.totp_verified, 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 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, &user.TOTPEnabled, &created, &updated, &expires) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &user.TOTPRequired, &user.TOTPEffectiveRequired, &totpVerified, &created, &updated, &expires)
if err != nil { if err != nil {
return user, time.Time{}, err return user, time.Time{}, false, err
} }
user.CreatedAt = created.Unix() user.CreatedAt = created.Unix()
user.UpdatedAt = updated.Unix() user.UpdatedAt = updated.Unix()
return user, expires, nil user.TOTPSetupRequired = user.TOTPEffectiveRequired && !user.TOTPEnabled
return user, expires, totpVerified, nil
} }
func (s *Store) CreateProject(project models.Project) (models.Project, error) { func (s *Store) CreateProject(project models.Project) (models.Project, error) {
@@ -1090,10 +1106,17 @@ func (s *Store) ListProjectsForUser(userID string) ([]models.Project, error) {
OR OR
(b.subject_type = 'group' AND b.subject_public_id IN ( (b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id SELECT g.public_id
FROM user_group_members gm FROM user_groups g
JOIN users ux ON ux.id = gm.user_id WHERE g.disabled = 0
JOIN user_groups g ON g.id = gm.group_id AND (
WHERE ux.public_id = ? AND g.disabled = 0 g.scope = 'all_users'
OR EXISTS (
SELECT 1
FROM user_group_members gm
JOIN users ux ON ux.id = gm.user_id
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
)) ))
) )
) )
@@ -1270,10 +1293,17 @@ func (s *Store) ListProjectsFilteredForUser(userID string, limit int, offset int
OR OR
(b.subject_type = 'group' AND b.subject_public_id IN ( (b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id SELECT g.public_id
FROM user_group_members gm FROM user_groups g
JOIN users ux ON ux.id = gm.user_id WHERE g.disabled = 0
JOIN user_groups g ON g.id = gm.group_id AND (
WHERE ux.public_id = ? AND g.disabled = 0 g.scope = 'all_users'
OR EXISTS (
SELECT 1
FROM user_group_members gm
JOIN users ux ON ux.id = gm.user_id
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
)) ))
) )
) )
@@ -1301,10 +1331,17 @@ func (s *Store) ListProjectsFilteredForUser(userID string, limit int, offset int
OR OR
(b.subject_type = 'group' AND b.subject_public_id IN ( (b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id SELECT g.public_id
FROM user_group_members gm FROM user_groups g
JOIN users ux ON ux.id = gm.user_id WHERE g.disabled = 0
JOIN user_groups g ON g.id = gm.group_id AND (
WHERE ux.public_id = ? AND g.disabled = 0 g.scope = 'all_users'
OR EXISTS (
SELECT 1
FROM user_group_members gm
JOIN users ux ON ux.id = gm.user_id
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
)) ))
) )
) AND (p.name LIKE ? OR p.slug LIKE ?) ) AND (p.name LIKE ? OR p.slug LIKE ?)
@@ -1465,10 +1502,17 @@ func (s *Store) CountProjectsFilteredForUser(userID string, query string) (int,
OR OR
(b.subject_type = 'group' AND b.subject_public_id IN ( (b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id SELECT g.public_id
FROM user_group_members gm FROM user_groups g
JOIN users ux ON ux.id = gm.user_id WHERE g.disabled = 0
JOIN user_groups g ON g.id = gm.group_id AND (
WHERE ux.public_id = ? AND g.disabled = 0 g.scope = 'all_users'
OR EXISTS (
SELECT 1
FROM user_group_members gm
JOIN users ux ON ux.id = gm.user_id
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
)) ))
) )
)`, )`,
@@ -1488,10 +1532,17 @@ func (s *Store) CountProjectsFilteredForUser(userID string, query string) (int,
OR OR
(b.subject_type = 'group' AND b.subject_public_id IN ( (b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id SELECT g.public_id
FROM user_group_members gm FROM user_groups g
JOIN users ux ON ux.id = gm.user_id WHERE g.disabled = 0
JOIN user_groups g ON g.id = gm.group_id AND (
WHERE ux.public_id = ? AND g.disabled = 0 g.scope = 'all_users'
OR EXISTS (
SELECT 1
FROM user_group_members gm
JOIN users ux ON ux.id = gm.user_id
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
)) ))
) )
) AND (p.name LIKE ? OR p.slug LIKE ?)`, ) AND (p.name LIKE ? OR p.slug LIKE ?)`,
@@ -1883,10 +1934,17 @@ func (s *Store) ListProjectIDsForUser(userID string) ([]string, error) {
b.subject_type = 'group' b.subject_type = 'group'
AND b.subject_public_id IN ( AND b.subject_public_id IN (
SELECT g.public_id SELECT g.public_id
FROM user_group_members gm FROM user_groups g
JOIN users ux ON ux.id = gm.user_id WHERE g.disabled = 0
JOIN user_groups g ON g.id = gm.group_id AND (
WHERE ux.public_id = ? AND g.disabled = 0 g.scope = 'all_users'
OR EXISTS (
SELECT 1
FROM user_group_members gm
JOIN users ux ON ux.id = gm.user_id
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
) )
)`, userID, userID) )`, userID, userID)
if err != nil { if err != nil {
+22 -10
View File
@@ -189,11 +189,17 @@ func (s *Store) ListUserPermissions(userID string) ([]string, error) {
sp.subject_type = 'group' sp.subject_type = 'group'
AND sp.subject_public_id IN ( AND sp.subject_public_id IN (
SELECT g.public_id SELECT g.public_id
FROM user_group_members gm FROM user_groups g
JOIN users u ON u.id = gm.user_id WHERE g.disabled = 0
JOIN user_groups g ON g.id = gm.group_id AND (
WHERE u.public_id = ? g.scope = 'all_users'
AND g.disabled = 0 OR EXISTS (
SELECT 1
FROM user_group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = g.id AND u.public_id = ?
)
)
) )
) )
ORDER BY sp.permission`, ORDER BY sp.permission`,
@@ -234,11 +240,17 @@ func (s *Store) UserHasPermission(userID string, permission string) (bool, error
sp.subject_type = 'group' sp.subject_type = 'group'
AND sp.subject_public_id IN ( AND sp.subject_public_id IN (
SELECT g.public_id SELECT g.public_id
FROM user_group_members gm FROM user_groups g
JOIN users u ON u.id = gm.user_id WHERE g.disabled = 0
JOIN user_groups g ON g.id = gm.group_id AND (
WHERE u.public_id = ? g.scope = 'all_users'
AND g.disabled = 0 OR EXISTS (
SELECT 1
FROM user_group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = g.id AND u.public_id = ?
)
)
) )
) )
) )
+19
View File
@@ -31,6 +31,25 @@ func (s *Store) GetUserTOTP(userID string) (UserTOTP, error) {
return item, nil return item, nil
} }
func (s *Store) UserRequiresTOTP(userID string) (bool, error) {
var row *sql.Row
var required bool
var err error
row = s.QueryRow(`
SELECT (u.totp_required OR EXISTS (
SELECT 1 FROM user_group_members m JOIN user_groups g ON g.id = m.group_id
WHERE m.user_id = u.id AND g.disabled = 0 AND g.totp_required = 1
) OR EXISTS (
SELECT 1 FROM user_groups g
WHERE g.disabled = 0 AND g.totp_required = 1 AND g.scope = 'all_users'
))
FROM users u
WHERE u.public_id = ? AND u.disabled = 0
`, userID)
err = row.Scan(&required)
return required, err
}
func (s *Store) UpsertUserTOTPPending(userID string, pendingSecretEncrypted string) error { func (s *Store) UpsertUserTOTPPending(userID string, pendingSecretEncrypted string) error {
var err error var err error
var now time.Time var now time.Time
+19 -3
View File
@@ -141,6 +141,7 @@ type createUserRequest struct {
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
TOTPRequired bool `json:"totp_required"`
} }
type updateMeRequest struct { type updateMeRequest struct {
@@ -430,6 +431,8 @@ type userGroupRequest struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
TOTPRequired bool `json:"totp_required"`
Scope string `json:"scope"`
} }
type userGroupMemberRequest struct { type userGroupMemberRequest struct {
@@ -499,7 +502,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
} }
func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models.User) { func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models.User, totpVerified bool) {
var token string var token string
var err error var err error
var expires time.Time var expires time.Time
@@ -510,7 +513,7 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
} }
expires = auth.SessionExpiry(api.Cfg) expires = auth.SessionExpiry(api.Cfg)
_ = 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, totpVerified)
cookie = &http.Cookie{ cookie = &http.Cookie{
Name: api.sessionCookieName(), Name: api.sessionCookieName(),
Value: token, Value: token,
@@ -542,6 +545,8 @@ func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]stri
func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var user models.User var user models.User
var permissions []string var permissions []string
var session middleware.SessionInfo
var response meResponse
var ok bool var ok bool
var err error var err error
user, ok = middleware.UserFromContext(r.Context()) user, ok = middleware.UserFromContext(r.Context())
@@ -554,7 +559,12 @@ func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string)
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
WriteJSON(w, http.StatusOK, buildMeResponse(user, permissions)) response = buildMeResponse(user, permissions)
session, ok = middleware.SessionFromContext(r.Context())
if ok {
response.TOTPVerifyRequired = user.TOTPEffectiveRequired && user.TOTPEnabled && !session.TOTPVerified
}
WriteJSON(w, http.StatusOK, response)
} }
func (api *API) UpdateMe(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) UpdateMe(w http.ResponseWriter, r *http.Request, _ map[string]string) {
@@ -654,6 +664,7 @@ func (api *API) CreateUser(w http.ResponseWriter, r *http.Request, _ map[string]
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
Email: req.Email, Email: req.Email,
IsAdmin: req.IsAdmin, IsAdmin: req.IsAdmin,
TOTPRequired: req.TOTPRequired,
AuthSource: "db", AuthSource: "db",
} }
created, err = api.store(r).CreateUser(user, hash) created, err = api.store(r).CreateUser(user, hash)
@@ -690,6 +701,7 @@ func (api *API) UpdateUser(w http.ResponseWriter, r *http.Request, params map[st
user.Email = payload.Email user.Email = payload.Email
} }
user.IsAdmin = payload.IsAdmin user.IsAdmin = payload.IsAdmin
user.TOTPRequired = payload.TOTPRequired
newPassword = payload.Password != "" newPassword = payload.Password != ""
if newPassword { if newPassword {
hash, err = auth.HashPassword(payload.Password) hash, err = auth.HashPassword(payload.Password)
@@ -786,6 +798,8 @@ func (api *API) CreateUserGroup(w http.ResponseWriter, r *http.Request, _ map[st
Name: req.Name, Name: req.Name,
Description: strings.TrimSpace(req.Description), Description: strings.TrimSpace(req.Description),
Disabled: req.Disabled, Disabled: req.Disabled,
TOTPRequired: req.TOTPRequired,
Scope: db.NormalizeUserGroupScope(req.Scope),
} }
group, err = api.store(r).CreateUserGroup(group) group, err = api.store(r).CreateUserGroup(group)
if err != nil { if err != nil {
@@ -820,6 +834,8 @@ func (api *API) UpdateUserGroup(w http.ResponseWriter, r *http.Request, params m
group.Name = req.Name group.Name = req.Name
group.Description = strings.TrimSpace(req.Description) group.Description = strings.TrimSpace(req.Description)
group.Disabled = req.Disabled group.Disabled = req.Disabled
group.TOTPRequired = req.TOTPRequired
group.Scope = db.NormalizeUserGroupScope(req.Scope)
group, err = api.store(r).UpdateUserGroup(group) group, err = api.store(r).UpdateUserGroup(group)
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()})
+16 -1
View File
@@ -105,6 +105,8 @@ func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[strin
var token oidcTokenResponse var token oidcTokenResponse
var claims oidcUserClaims var claims oidcUserClaims
var user models.User var user models.User
var required bool
var sessionVerified bool
settings, err = api.getMergedAuthSettings(r.Context()) settings, err = api.getMergedAuthSettings(r.Context())
if err != nil { if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load auth settings"}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load auth settings"})
@@ -153,7 +155,20 @@ func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[strin
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "oidc user mapping failed"}) WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "oidc user mapping failed"})
return return
} }
api.issueSession(w, r, user) required, err = api.store(r).UserRequiresTOTP(user.ID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
user, err = api.store(r).GetUserByID(user.ID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
user.TOTPEffectiveRequired = required
user.TOTPSetupRequired = required && !user.TOTPEnabled
sessionVerified = !required
api.issueSession(w, r, user, sessionVerified)
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login success username=%s", user.Username) api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login success username=%s", user.Username)
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
} }
@@ -18,6 +18,10 @@ type meResponse struct {
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"` AuthSource string `json:"auth_source"`
TOTPEnabled bool `json:"totp_enabled"` TOTPEnabled bool `json:"totp_enabled"`
TOTPRequired bool `json:"totp_required"`
TOTPEffectiveRequired bool `json:"totp_effective_required"`
TOTPSetupRequired bool `json:"totp_setup_required"`
TOTPVerifyRequired bool `json:"totp_verify_required"`
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"`
@@ -174,6 +178,10 @@ func buildMeResponse(user models.User, permissions []string) meResponse {
Disabled: user.Disabled, Disabled: user.Disabled,
AuthSource: user.AuthSource, AuthSource: user.AuthSource,
TOTPEnabled: user.TOTPEnabled, TOTPEnabled: user.TOTPEnabled,
TOTPRequired: user.TOTPRequired,
TOTPEffectiveRequired: user.TOTPEffectiveRequired,
TOTPSetupRequired: user.TOTPSetupRequired,
TOTPVerifyRequired: false,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt, UpdatedAt: user.UpdatedAt,
Permissions: permissions, Permissions: permissions,
+124 -5
View File
@@ -2,6 +2,7 @@ package handlers
import "database/sql" import "database/sql"
import "net/http" import "net/http"
import "net/url"
import "strings" import "strings"
import "time" import "time"
@@ -12,6 +13,9 @@ import "codit/internal/models"
type totpStatusResponse struct { type totpStatusResponse struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Required bool `json:"required"`
SetupRequired bool `json:"setup_required"`
VerifyRequired bool `json:"verify_required"`
} }
type totpSetupResponse struct { type totpSetupResponse struct {
@@ -31,6 +35,15 @@ type totpDisableRequest struct {
func (api *API) finishLogin(w http.ResponseWriter, r *http.Request, user models.User, otpCode string) { func (api *API) finishLogin(w http.ResponseWriter, r *http.Request, user models.User, otpCode string) {
var ok bool var ok bool
var err error var err error
var required bool
var sessionVerified bool
required, err = api.store(r).UserRequiresTOTP(user.ID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
user.TOTPEffectiveRequired = required
user.TOTPSetupRequired = required && !user.TOTPEnabled
ok, err = api.verifyUserTOTPForLogin(r, user, otpCode) ok, err = api.verifyUserTOTPForLogin(r, user, otpCode)
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()})
@@ -44,7 +57,11 @@ func (api *API) finishLogin(w http.ResponseWriter, r *http.Request, user models.
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_totp"}) WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_totp"})
return return
} }
api.issueSession(w, r, user) sessionVerified = !user.TOTPEnabled || ok
if required && !user.TOTPEnabled {
sessionVerified = false
}
api.issueSession(w, r, user, sessionVerified)
WriteJSON(w, http.StatusOK, user) WriteJSON(w, http.StatusOK, user)
} }
@@ -74,6 +91,8 @@ func (api *API) GetMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]s
var ok bool var ok bool
var item db.UserTOTP var item db.UserTOTP
var err error var err error
var session middleware.SessionInfo
var sessionOK bool
ctxUser, ok = middleware.UserFromContext(r.Context()) ctxUser, ok = middleware.UserFromContext(r.Context())
if !ok { if !ok {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
@@ -84,7 +103,13 @@ func (api *API) GetMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]s
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: err == nil && item.Enabled}) session, sessionOK = middleware.SessionFromContext(r.Context())
WriteJSON(w, http.StatusOK, totpStatusResponse{
Enabled: err == nil && item.Enabled,
Required: ctxUser.TOTPEffectiveRequired,
SetupRequired: ctxUser.TOTPEffectiveRequired && !(err == nil && item.Enabled),
VerifyRequired: ctxUser.TOTPEffectiveRequired && err == nil && item.Enabled && (!sessionOK || !session.TOTPVerified),
})
} }
func (api *API) SetupMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) SetupMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) {
@@ -114,10 +139,64 @@ func (api *API) SetupMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
url = auth.TOTPProvisioningURL(api.serverTitle(), ctxUser.Username, secret) url = auth.TOTPProvisioningURL(api.totpIssuer(r), ctxUser.Username, secret)
WriteJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, OTPAuthURL: url}) WriteJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, OTPAuthURL: url})
} }
func (api *API) totpIssuer(r *http.Request) string {
var raw string
var parsed *url.URL
var err error
var host string
raw = strings.TrimSpace(api.Cfg.PublicBaseURL)
if raw != "" {
parsed, err = url.Parse(raw)
if err == nil {
host = strings.TrimSpace(parsed.Host)
if host != "" {
return host
}
}
}
host = cleanTOTPHost(r.Header.Get("X-Forwarded-Host"))
if host != "" {
return host
}
host = cleanTOTPHost(r.Host)
if host != "" {
return host
}
return api.serverTitle()
}
func cleanTOTPHost(value string) string {
var parts []string
var raw string
var parsed *url.URL
var err error
parts = strings.Split(value, ",")
if len(parts) <= 0 {
return ""
}
raw = strings.TrimSpace(parts[0])
if raw == "" {
return ""
}
if strings.Contains(raw, "://") {
parsed, err = url.Parse(raw)
if err == nil && strings.TrimSpace(parsed.Host) != "" {
return strings.TrimSpace(parsed.Host)
}
}
if strings.Contains(raw, "/") {
parts = strings.Split(raw, "/")
raw = strings.TrimSpace(parts[0])
}
return raw
}
func (api *API) EnableMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) EnableMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var ctxUser models.User var ctxUser models.User
var ok bool var ok bool
@@ -163,7 +242,47 @@ func (api *API) EnableMyTOTP(w http.ResponseWriter, r *http.Request, _ map[strin
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: true}) api.markCurrentSessionTOTPVerified(r)
WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: true, Required: ctxUser.TOTPEffectiveRequired, SetupRequired: false, VerifyRequired: false})
}
func (api *API) VerifyMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var ctxUser models.User
var ok bool
var req totpEnableRequest
var verified bool
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
}
verified, err = api.verifyUserTOTPForLogin(r, ctxUser, req.OTPCode)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !verified {
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_totp"})
return
}
api.markCurrentSessionTOTPVerified(r)
WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: true, Required: ctxUser.TOTPEffectiveRequired, SetupRequired: false, VerifyRequired: false})
}
func (api *API) markCurrentSessionTOTPVerified(r *http.Request) {
var session middleware.SessionInfo
var ok bool
session, ok = middleware.SessionFromContext(r.Context())
if !ok || session.Token == "" {
return
}
_ = api.store(r).SetSessionTOTPVerified(session.Token)
} }
func (api *API) DisableMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) DisableMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]string) {
@@ -206,7 +325,7 @@ func (api *API) DisableMyTOTP(w http.ResponseWriter, r *http.Request, _ map[stri
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: false}) WriteJSON(w, http.StatusOK, totpStatusResponse{Enabled: false, Required: ctxUser.TOTPEffectiveRequired, SetupRequired: ctxUser.TOTPEffectiveRequired, VerifyRequired: false})
} }
func (api *API) ResetUserTOTPAdmin(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) ResetUserTOTPAdmin(w http.ResponseWriter, r *http.Request, params map[string]string) {
+85 -1
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import "context" import "context"
import "encoding/json"
import "net/http" import "net/http"
import "strings" import "strings"
import "time" import "time"
@@ -13,6 +14,12 @@ type ctxKey string
const userKey ctxKey = "user" const userKey ctxKey = "user"
const principalKey ctxKey = "principal" const principalKey ctxKey = "principal"
const sessionKey ctxKey = "session"
type SessionInfo struct {
Token string
TOTPVerified bool
}
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) return WithUserCookie(store, "codit_session", next)
@@ -31,6 +38,7 @@ func WithUserCookie(store *db.Store, sessionCookieName string, next http.Handler
var ok bool var ok bool
var user models.User var user models.User
var expires time.Time var expires time.Time
var totpVerified bool
var ctx context.Context var ctx context.Context
var token string var token string
var hash string var hash string
@@ -66,7 +74,7 @@ func WithUserCookie(store *db.Store, sessionCookieName string, next http.Handler
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
return return
} }
user, expires, err = activeStore.GetSessionUser(cookie.Value) user, expires, totpVerified, err = activeStore.GetSessionUser(cookie.Value)
if err != nil || time.Now().UTC().After(expires) { if err != nil || time.Now().UTC().After(expires) {
token = apiKeyFromRequest(r) token = apiKeyFromRequest(r)
if token == "" { if token == "" {
@@ -93,6 +101,7 @@ func WithUserCookie(store *db.Store, sessionCookieName string, next http.Handler
} }
rememberUser(r.Context(), user) rememberUser(r.Context(), user)
ctx = context.WithValue(r.Context(), userKey, user) ctx = context.WithValue(r.Context(), userKey, user)
ctx = context.WithValue(ctx, sessionKey, SessionInfo{Token: cookie.Value, TOTPVerified: totpVerified})
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
@@ -126,6 +135,81 @@ func PrincipalFromContext(ctx context.Context) (models.ServicePrincipal, bool) {
return principal, ok return principal, ok
} }
func SessionFromContext(ctx context.Context) (SessionInfo, bool) {
var session SessionInfo
var ok bool
session, ok = ctx.Value(sessionKey).(SessionInfo)
return session, ok
}
func RequireInteractiveTOTP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var user models.User
var session SessionInfo
var ok bool
user, ok = UserFromContext(r.Context())
if !ok {
next.ServeHTTP(w, r)
return
}
session, ok = SessionFromContext(r.Context())
if !ok {
next.ServeHTTP(w, r)
return
}
if !user.TOTPEffectiveRequired {
next.ServeHTTP(w, r)
return
}
if user.TOTPEnabled && session.TOTPVerified {
next.ServeHTTP(w, r)
return
}
if isTOTPRestrictedPathAllowed(r) {
next.ServeHTTP(w, r)
return
}
writeTOTPRestrictionError(w, user)
})
}
func isTOTPRestrictedPathAllowed(r *http.Request) bool {
var path string
path = r.URL.Path
if path == "/api/logout" {
return true
}
if path == "/api/me" {
return r.Method == http.MethodGet || r.Method == http.MethodPatch
}
if path == "/api/me/totp" {
return r.Method == http.MethodGet
}
if path == "/api/me/totp/setup" {
return r.Method == http.MethodPost
}
if path == "/api/me/totp/enable" {
return r.Method == http.MethodPost
}
if path == "/api/me/totp/verify" {
return r.Method == http.MethodPost
}
return false
}
func writeTOTPRestrictionError(w http.ResponseWriter, user models.User) {
var payload map[string]any
payload = map[string]any{
"error": "totp_setup_required",
"totp_required": true,
"totp_setup_required": !user.TOTPEnabled,
"totp_verify_required": user.TOTPEnabled,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(payload)
}
func RequireAuth(next http.Handler) http.Handler { func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ok bool var ok bool
+5
View File
@@ -9,6 +9,9 @@ type User struct {
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"` AuthSource string `json:"auth_source"`
TOTPEnabled bool `json:"totp_enabled"` TOTPEnabled bool `json:"totp_enabled"`
TOTPRequired bool `json:"totp_required"`
TOTPEffectiveRequired bool `json:"totp_effective_required"`
TOTPSetupRequired bool `json:"totp_setup_required"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
@@ -56,6 +59,8 @@ type UserGroup struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
TOTPRequired bool `json:"totp_required"`
Scope string `json:"scope"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE users ADD COLUMN totp_required INTEGER NOT NULL DEFAULT 0;
ALTER TABLE user_groups ADD COLUMN totp_required INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sessions ADD COLUMN totp_verified INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
ALTER TABLE user_groups ADD COLUMN scope TEXT NOT NULL DEFAULT 'explicit';
CREATE UNIQUE INDEX IF NOT EXISTS user_groups_one_all_users ON user_groups(scope) WHERE scope = 'all_users';
+16 -14
View File
@@ -422,43 +422,43 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
err = os.MkdirAll(cfg.DataDir, 0o755) err = os.MkdirAll(cfg.DataDir, 0o755)
if err != nil { if err != nil {
err = fmt.Errorf("data dir error: %v", err) err = fmt.Errorf("data dir error - %v", err)
goto oops goto oops
} }
app.store, err = db.Open(cfg.DBDriver, cfg.DBDSN) app.store, err = db.Open(cfg.DBDriver, cfg.DBDSN)
if err != nil { if err != nil {
err = fmt.Errorf("db open error: %v", err) err = fmt.Errorf("db open error - %v", err)
goto oops goto oops
} }
err = app.store.ApplyMigrationsFS(migrationFiles, "migrations") err = app.store.ApplyMigrationsFS(migrationFiles, "migrations")
if err != nil { if err != nil {
err = fmt.Errorf("migrations error: %v", err) err = fmt.Errorf("migrations error - %v", err)
goto oops goto oops
} }
err = app.store.EnsureDefaultTLSAuthPolicies() err = app.store.EnsureDefaultTLSAuthPolicies()
if err != nil { if err != nil {
err = fmt.Errorf("tls auth policy init error: %v", err) err = fmt.Errorf("tls auth policy init error - %v", err)
goto oops goto oops
} }
err = app.store.EnsureTLSAuthPolicyBindings() err = app.store.EnsureTLSAuthPolicyBindings()
if err != nil { if err != nil {
err = fmt.Errorf("tls auth binding init error: %v", err) err = fmt.Errorf("tls auth binding init error - %v", err)
goto oops goto oops
} }
err = mergeTLSSettingsFromDB(cfg, app.store, EnvPrefixFromServerIdentifier(app.serverIdentifier)) 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, EnvPrefixFromServerIdentifier(app.serverIdentifier)) 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
} }
@@ -470,7 +470,7 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
for _, dir = range []string{app.docker_base_dir, app.git_base_dir, app.rpm_base_dir, app.uploads_base_dir} { for _, dir = range []string{app.docker_base_dir, app.git_base_dir, app.rpm_base_dir, app.uploads_base_dir} {
err = os.MkdirAll(dir, 0o755) err = os.MkdirAll(dir, 0o755)
if err != nil { if err != nil {
err = fmt.Errorf("dir error (%s): %v", dir, err) err = fmt.Errorf("dir error (%s) - %v", dir, err)
goto oops goto oops
} }
} }
@@ -479,7 +479,7 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
app.git_server, err = git.NewHTTPServer(app.git_base_dir, nil, logger) app.git_server, err = git.NewHTTPServer(app.git_base_dir, nil, logger)
if err != nil { if err != nil {
err = fmt.Errorf("git server error: %v", err) err = fmt.Errorf("git server error - %v", err)
goto oops goto oops
} }
@@ -527,7 +527,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
err = extraListenerManager.Start() err = extraListenerManager.Start()
if err != nil { if err != nil {
app.logger.Write("", LOG_ERROR, "additional listener manager error: %v", err) app.logger.Write("", LOG_ERROR, "additional listener manager error - %v", err)
shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
extraListenerManager.Stop(shutdownCtx) extraListenerManager.Stop(shutdownCtx)
app.rpm_mirror.Stop() app.rpm_mirror.Stop()
@@ -538,7 +538,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
mainEndpoints, err = buildListenerEndpoints("main", "main", "", tlsSettingsFromConfig(*app.cfg), app.store) mainEndpoints, err = buildListenerEndpoints("main", "main", "", tlsSettingsFromConfig(*app.cfg), app.store)
if err != nil { if err != nil {
app.logger.Write("", LOG_ERROR, "main listener config error: %v", err) app.logger.Write("", LOG_ERROR, "main listener config error - %v", err)
shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
extraListenerManager.Stop(shutdownCtx) extraListenerManager.Stop(shutdownCtx)
app.rpm_mirror.Stop() app.rpm_mirror.Stop()
@@ -548,7 +548,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
} }
if len(mainEndpoints) == 0 && extraListenerManager.TotalEndpointCount() == 0 { if len(mainEndpoints) == 0 && extraListenerManager.TotalEndpointCount() == 0 {
err = errors.New("at least one main or additional listener is required") err = errors.New("at least one main or additional listener is required")
app.logger.Write("", LOG_ERROR, "listener config error: %v", err) app.logger.Write("", LOG_ERROR, "listener config error - %v", err)
shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
extraListenerManager.Stop(shutdownCtx) extraListenerManager.Stop(shutdownCtx)
app.rpm_mirror.Stop() app.rpm_mirror.Stop()
@@ -578,7 +578,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
} }
cancel() cancel()
if err != nil { if err != nil {
app.logger.Write("", LOG_ERROR, "server error: %v", err) app.logger.Write("", LOG_ERROR, "server error - %v", err)
return err return err
} }
return nil return nil
@@ -619,6 +619,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/me/totp", api.GetMyTOTP) router.Handle("GET", "/api/me/totp", api.GetMyTOTP)
router.Handle("POST", "/api/me/totp/setup", api.SetupMyTOTP) router.Handle("POST", "/api/me/totp/setup", api.SetupMyTOTP)
router.Handle("POST", "/api/me/totp/enable", api.EnableMyTOTP) router.Handle("POST", "/api/me/totp/enable", api.EnableMyTOTP)
router.Handle("POST", "/api/me/totp/verify", api.VerifyMyTOTP)
router.Handle("POST", "/api/me/totp/disable", api.DisableMyTOTP) router.Handle("POST", "/api/me/totp/disable", api.DisableMyTOTP)
router.Handle("GET", "/api/users", api.ListUsers) router.Handle("GET", "/api/users", api.ListUsers)
@@ -914,6 +915,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.RequireInteractiveTOTP(apiHandler)
apiHandler = middleware.WithUserCookie(store, sessionCookieNameFromServerIdentifier(app.serverIdentifier), 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)
@@ -1098,7 +1100,7 @@ func (m *additionalListenerManager) Start() error {
case <-m.Reload: case <-m.Reload:
reconcileErr = m.reconcile() reconcileErr = m.reconcile()
if reconcileErr != nil { if reconcileErr != nil {
m.Logger.Write("", LOG_ERROR, "additional listener reconcile error: %v", reconcileErr) m.Logger.Write("", LOG_ERROR, "additional listener reconcile error - %v", reconcileErr)
} }
} }
} }
+15
View File
@@ -6,6 +6,10 @@ export interface User {
is_admin: boolean is_admin: boolean
auth_source?: string auth_source?: string
totp_enabled?: boolean totp_enabled?: boolean
totp_required?: boolean
totp_effective_required?: boolean
totp_setup_required?: boolean
totp_verify_required?: boolean
disabled?: boolean disabled?: boolean
permissions?: string[] permissions?: string[]
} }
@@ -17,6 +21,9 @@ export type ServerInfo = {
export type TOTPStatus = { export type TOTPStatus = {
enabled: boolean enabled: boolean
required?: boolean
setup_required?: boolean
verify_required?: boolean
} }
export type TOTPSetupResponse = { export type TOTPSetupResponse = {
@@ -30,6 +37,7 @@ export type UserCreatePayload = {
email: string email: string
password: string password: string
is_admin: boolean is_admin: boolean
totp_required: boolean
} }
export type UserUpdatePayload = { export type UserUpdatePayload = {
@@ -37,12 +45,15 @@ export type UserUpdatePayload = {
email?: string email?: string
password?: string password?: string
is_admin: boolean is_admin: boolean
totp_required: boolean
} }
export type UserGroupUpsertPayload = { export type UserGroupUpsertPayload = {
name: string name: string
description: string description: string
disabled: boolean disabled: boolean
totp_required: boolean
scope: 'explicit' | 'all_users'
} }
export type MeUpdatePayload = { export type MeUpdatePayload = {
@@ -345,6 +356,8 @@ export interface UserGroup {
name: string name: string
description: string description: string
disabled: boolean disabled: boolean
totp_required: boolean
scope: 'explicit' | 'all_users'
created_at: number created_at: number
updated_at: number updated_at: number
} }
@@ -1145,6 +1158,8 @@ export const api = {
setupMyTOTP: () => request<TOTPSetupResponse>('/api/me/totp/setup', { method: 'POST' }), setupMyTOTP: () => request<TOTPSetupResponse>('/api/me/totp/setup', { method: 'POST' }),
enableMyTOTP: (otpCode: string) => enableMyTOTP: (otpCode: string) =>
request<TOTPStatus>('/api/me/totp/enable', { method: 'POST', body: JSON.stringify({ otp_code: otpCode }) }), request<TOTPStatus>('/api/me/totp/enable', { method: 'POST', body: JSON.stringify({ otp_code: otpCode }) }),
verifyMyTOTP: (otpCode: string) =>
request<TOTPStatus>('/api/me/totp/verify', { method: 'POST', body: JSON.stringify({ otp_code: otpCode }) }),
disableMyTOTP: (password: string, otpCode: string) => disableMyTOTP: (password: string, otpCode: string) =>
request<TOTPStatus>('/api/me/totp/disable', { method: 'POST', body: JSON.stringify({ password, otp_code: otpCode }) }), 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'),
+7 -2
View File
@@ -76,10 +76,15 @@ export default function Layout() {
useEffect(() => { useEffect(() => {
api api
.me() .me()
.then((me) => setUser(me)) .then((me) => {
setUser(me)
if ((me.totp_setup_required || me.totp_verify_required) && location.pathname !== '/account') {
navigate('/account')
}
})
.catch(() => setUser(null)) .catch(() => setUser(null))
.finally(() => setAuthChecked(true)) .finally(() => setAuthChecked(true))
}, []) }, [location.pathname, navigate])
const navSections = useMemo(() => { const navSections = useMemo(() => {
const workspaceItems: NavEntry[] = [ const workspaceItems: NavEntry[] = [
@@ -17,6 +17,7 @@ export type UserFormState = {
password: string password: string
passwordConfirm: string passwordConfirm: string
isAdmin: boolean isAdmin: boolean
totpRequired: boolean
canCreateProject: boolean canCreateProject: boolean
} }
@@ -80,6 +81,10 @@ export default function UserFormDialog(props: UserFormDialogProps) {
control={<Checkbox checked={props.form.isAdmin} onChange={(event) => props.setForm((prev) => ({ ...prev, isAdmin: event.target.checked }))} />} control={<Checkbox checked={props.form.isAdmin} onChange={(event) => props.setForm((prev) => ({ ...prev, isAdmin: event.target.checked }))} />}
label="Admin" label="Admin"
/> />
<FormControlLabel
control={<Checkbox checked={props.form.totpRequired} onChange={(event) => props.setForm((prev) => ({ ...prev, totpRequired: event.target.checked }))} />}
label="Require TOTP for interactive sessions"
/>
<FormControlLabel <FormControlLabel
control={<Checkbox checked={props.form.canCreateProject} onChange={(event) => props.setForm((prev) => ({ ...prev, canCreateProject: event.target.checked }))} />} control={<Checkbox checked={props.form.canCreateProject} onChange={(event) => props.setForm((prev) => ({ ...prev, canCreateProject: event.target.checked }))} />}
label="Allow project creation (direct grant)" label="Allow project creation (direct grant)"
+43 -2
View File
@@ -17,6 +17,9 @@ export default function AccountPage() {
const [totpSetup, setTOTPSetup] = useState<TOTPSetupResponse | null>(null) const [totpSetup, setTOTPSetup] = useState<TOTPSetupResponse | null>(null)
const [totpCode, setTOTPCode] = useState('') const [totpCode, setTOTPCode] = useState('')
const [totpPassword, setTOTPPassword] = useState('') const [totpPassword, setTOTPPassword] = useState('')
const [totpRequired, setTOTPRequired] = useState<boolean>(false)
const [totpSetupRequired, setTOTPSetupRequired] = useState<boolean>(false)
const [totpVerifyRequired, setTOTPVerifyRequired] = useState<boolean>(false)
const [totpBusy, setTOTPBusy] = useState(false) const [totpBusy, setTOTPBusy] = useState(false)
const [totpError, setTOTPError] = useState<string | null>(null) const [totpError, setTOTPError] = useState<string | null>(null)
@@ -28,6 +31,9 @@ export default function AccountPage() {
setDisplayName(me.display_name || '') setDisplayName(me.display_name || '')
setEmail(me.email || '') setEmail(me.email || '')
setTOTPEnabled(Boolean(me.totp_enabled)) setTOTPEnabled(Boolean(me.totp_enabled))
setTOTPRequired(Boolean(me.totp_effective_required))
setTOTPSetupRequired(Boolean(me.totp_setup_required))
setTOTPVerifyRequired(Boolean(me.totp_verify_required))
}) })
.catch(() => { .catch(() => {
setUser(null) setUser(null)
@@ -36,6 +42,9 @@ export default function AccountPage() {
.getMyTOTP() .getMyTOTP()
.then((status) => { .then((status) => {
setTOTPEnabled(Boolean(status.enabled)) setTOTPEnabled(Boolean(status.enabled))
setTOTPRequired(Boolean(status.required))
setTOTPSetupRequired(Boolean(status.setup_required))
setTOTPVerifyRequired(Boolean(status.verify_required))
}) })
.catch(() => { .catch(() => {
setTOTPEnabled(false) setTOTPEnabled(false)
@@ -94,6 +103,8 @@ export default function AccountPage() {
try { try {
await api.enableMyTOTP(totpCode.trim()) await api.enableMyTOTP(totpCode.trim())
setTOTPEnabled(true) setTOTPEnabled(true)
setTOTPSetupRequired(false)
setTOTPVerifyRequired(false)
setTOTPSetup(null) setTOTPSetup(null)
setTOTPCode('') setTOTPCode('')
} catch (err) { } catch (err) {
@@ -104,6 +115,22 @@ export default function AccountPage() {
} }
} }
const handleVerifyTOTP = async () => {
let message: string
setTOTPBusy(true)
setTOTPError(null)
try {
await api.verifyMyTOTP(totpCode.trim())
setTOTPVerifyRequired(false)
setTOTPCode('')
} catch (err) {
message = err instanceof Error ? err.message : 'Failed to verify TOTP'
setTOTPError(message)
} finally {
setTOTPBusy(false)
}
}
const handleDisableTOTP = async () => { const handleDisableTOTP = async () => {
let message: string let message: string
setTOTPBusy(true) setTOTPBusy(true)
@@ -170,9 +197,23 @@ export default function AccountPage() {
</Typography> </Typography>
{totpError ? <Alert severity="error" sx={{ mb: 1 }}>{totpError}</Alert> : null} {totpError ? <Alert severity="error" sx={{ mb: 1 }}>{totpError}</Alert> : null}
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Status: {totpEnabled ? 'Enabled' : 'Disabled'} Status: {totpEnabled ? 'Enabled' : 'Disabled'}{totpRequired ? ' · Required' : ''}{totpSetupRequired ? ' · Setup required' : ''}{totpVerifyRequired ? ' · Verification required' : ''}
</Typography> </Typography>
{!totpEnabled ? ( {totpVerifyRequired ? (
<Box sx={{ display: 'grid', gap: 1 }}>
<Alert severity="warning">Enter your authenticator code to finish this interactive session.</Alert>
<TextField
label="Authenticator Code"
value={totpCode}
onChange={(event) => setTOTPCode(event.target.value)}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" onClick={handleVerifyTOTP} disabled={totpBusy || totpCode.trim() === ''}>
Verify TOTP
</Button>
</Box>
</Box>
) : !totpEnabled ? (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Button variant="outlined" onClick={handleSetupTOTP} disabled={totpBusy}> <Button variant="outlined" onClick={handleSetupTOTP} disabled={totpBusy}>
Start TOTP Setup Start TOTP Setup
+5 -4
View File
@@ -67,9 +67,10 @@ export default function AdminSSHSessionsPage() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
nextQuery = query.trim() nextQuery = query.trim(); // note this semicolon is super important. otherwide trim() joins with [..] on the next line
;[response, profileList, serverList] = await Promise.all([ [response, profileList, serverList] = await Promise.all([
api.listSSHSessionsAdmin({ api.listSSHSessionsAdmin({
limit, limit,
offset, offset,
@@ -81,10 +82,10 @@ export default function AdminSSHSessionsPage() {
]) ])
profileMap = {} profileMap = {}
serverMap = {} serverMap = {}
for (i = 0; i < profileList.length; i++) { for (i = 0; i < (profileList?.length ?? 0); i++) {
profileMap[profileList[i].id] = profileList[i] profileMap[profileList[i].id] = profileList[i]
} }
for (i = 0; i < serverList.length; i++) { for (i = 0; i < (serverList?.length ?? 0); i++) {
serverMap[serverList[i].id] = serverList[i] serverMap[serverList[i].id] = serverList[i]
} }
setItems(Array.isArray(response.items) ? response.items : []) setItems(Array.isArray(response.items) ? response.items : [])
+37 -5
View File
@@ -56,6 +56,8 @@ export default function AdminUserGroupsPage() {
const [name, setName] = useState('') const [name, setName] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [disabled, setDisabled] = useState(false) const [disabled, setDisabled] = useState(false)
const [totpRequired, setTOTPRequired] = useState<boolean>(false)
const [scope, setScope] = useState<'explicit' | 'all_users'>('explicit')
const [deleteItem, setDeleteItem] = useState<UserGroup | null>(null) const [deleteItem, setDeleteItem] = useState<UserGroup | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('') const [deleteConfirm, setDeleteConfirm] = useState('')
@@ -149,6 +151,8 @@ export default function AdminUserGroupsPage() {
setName('') setName('')
setDescription('') setDescription('')
setDisabled(false) setDisabled(false)
setTOTPRequired(false)
setScope('explicit')
setEditOpen(true) setEditOpen(true)
} }
@@ -158,6 +162,8 @@ export default function AdminUserGroupsPage() {
setName(item.name) setName(item.name)
setDescription(item.description || '') setDescription(item.description || '')
setDisabled(item.disabled) setDisabled(item.disabled)
setTOTPRequired(Boolean(item.totp_required))
setScope(item.scope === 'all_users' ? 'all_users' : 'explicit')
setEditOpen(true) setEditOpen(true)
} }
@@ -167,7 +173,7 @@ export default function AdminUserGroupsPage() {
setDialogError(null) setDialogError(null)
setError(null) setError(null)
try { try {
payload = { name: name.trim(), description: description.trim(), disabled } payload = { name: name.trim(), description: description.trim(), disabled, totp_required: totpRequired, scope }
if (editID) { if (editID) {
await api.updateUserGroup(editID, payload) await api.updateUserGroup(editID, payload)
} else { } else {
@@ -321,7 +327,7 @@ export default function AdminUserGroupsPage() {
<ListItemText <ListItemText
sx={{ minWidth: 0, flex: 1 }} sx={{ minWidth: 0, flex: 1 }}
primary={group.name} primary={group.name}
secondary={`${group.id} · updated: ${fmt(group.updated_at)}${hasDirectProjectCreate(group.id) ? ' · project.create' : ''}`} secondary={`${group.id} · ${group.scope === 'all_users' ? 'all users' : 'explicit'} · updated: ${fmt(group.updated_at)}${group.totp_required ? ' · TOTP required' : ''}${hasDirectProjectCreate(group.id) ? ' · project.create' : ''}`}
primaryTypographyProps={{ primaryTypographyProps={{
noWrap: true, noWrap: true,
title: group.name + (group.disabled ? ' (disabled)' : '') title: group.name + (group.disabled ? ' (disabled)' : '')
@@ -331,6 +337,7 @@ export default function AdminUserGroupsPage() {
}} }}
/> />
{group.disabled ? <Chip size="small" label="Disabled" sx={{ mt: 0.25, flexShrink: 0 }} /> : null} {group.disabled ? <Chip size="small" label="Disabled" sx={{ mt: 0.25, flexShrink: 0 }} /> : null}
{group.scope === 'all_users' ? <Chip size="small" label="All Users" sx={{ mt: 0.25, flexShrink: 0 }} /> : null}
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.25, flexShrink: 0 }}> <Box sx={{ display: 'flex', gap: 0.5, mt: 0.25, flexShrink: 0 }}>
<IconButton <IconButton
size="small" size="small"
@@ -349,6 +356,7 @@ export default function AdminUserGroupsPage() {
setDeleteItem(group) setDeleteItem(group)
setDeleteConfirm('') setDeleteConfirm('')
}} }}
disabled={group.scope === 'all_users'}
> >
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
@@ -375,10 +383,16 @@ export default function AdminUserGroupsPage() {
{hasDirectProjectCreate(selectedGroup.id) ? ( {hasDirectProjectCreate(selectedGroup.id) ? (
<Chip size="small" variant="outlined" label="project.create" /> <Chip size="small" variant="outlined" label="project.create" />
) : null} ) : null}
{selectedGroup.totp_required ? (
<Chip size="small" variant="outlined" label="TOTP required" />
) : null}
{selectedGroup.scope === 'all_users' ? (
<Chip size="small" variant="outlined" label="All users" />
) : null}
</Box> </Box>
</Box> </Box>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{selectedGroup.id} · updated: {fmt(selectedGroup.updated_at)} {selectedGroup.id} · scope: {selectedGroup.scope === 'all_users' ? 'all users' : 'explicit'} · updated: {fmt(selectedGroup.updated_at)}
</Typography> </Typography>
{selectedGroup.description ? ( {selectedGroup.description ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -400,7 +414,7 @@ export default function AdminUserGroupsPage() {
<SectionCard <SectionCard
title="Members" title="Members"
actions={ actions={
selectedGroup ? ( selectedGroup && selectedGroup.scope !== 'all_users' ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<SelectField <SelectField
select select
@@ -442,12 +456,17 @@ export default function AdminUserGroupsPage() {
setDeleteMemberItem(member) setDeleteMemberItem(member)
setDeleteMemberConfirm('') setDeleteMemberConfirm('')
}} }}
disabled={busy} disabled={busy || selectedGroup.scope === 'all_users'}
> >
Delete Delete
</Button> </Button>
</Box> </Box>
))} ))}
{selectedGroup.scope === 'all_users' ? (
<Typography variant="body2" color="text.secondary">
This virtual group includes all active interactive users.
</Typography>
) : null}
{members.length === 0 ? ( {members.length === 0 ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
No members. No members.
@@ -495,10 +514,23 @@ export default function AdminUserGroupsPage() {
multiline multiline
minRows={2} minRows={2}
/> />
<SelectField
select
label="Scope"
value={scope}
onChange={(event) => setScope(event.target.value === 'all_users' ? 'all_users' : 'explicit')}
>
<MenuItem value="explicit">Explicit members</MenuItem>
<MenuItem value="all_users">All users</MenuItem>
</SelectField>
<FormControlLabel <FormControlLabel
control={<Checkbox checked={disabled} onChange={(event) => setDisabled(event.target.checked)} />} control={<Checkbox checked={disabled} onChange={(event) => setDisabled(event.target.checked)} />}
label="Disabled" label="Disabled"
/> />
<FormControlLabel
control={<Checkbox checked={totpRequired} onChange={(event) => setTOTPRequired(event.target.checked)} />}
label="Require TOTP for members"
/>
</FormDialogContent> </FormDialogContent>
<DialogActions> <DialogActions>
<Button variant="contained" onClick={saveGroup} disabled={busy || !name.trim()}> <Button variant="contained" onClick={saveGroup} disabled={busy || !name.trim()}>
+7 -3
View File
@@ -25,6 +25,7 @@ function createEmptyForm(): UserFormState {
password: '', password: '',
passwordConfirm: '', passwordConfirm: '',
isAdmin: false, isAdmin: false,
totpRequired: false,
canCreateProject: false canCreateProject: false
} }
} }
@@ -90,7 +91,8 @@ export default function AdminUsersPage() {
display_name: createForm.displayName.trim(), display_name: createForm.displayName.trim(),
email: createForm.email.trim(), email: createForm.email.trim(),
password: createForm.password, password: createForm.password,
is_admin: createForm.isAdmin is_admin: createForm.isAdmin,
totp_required: createForm.totpRequired
} }
if (createForm.password !== createForm.passwordConfirm) { if (createForm.password !== createForm.passwordConfirm) {
setCreateError('Password and confirmation must match.') setCreateError('Password and confirmation must match.')
@@ -189,6 +191,7 @@ export default function AdminUsersPage() {
password: '', password: '',
passwordConfirm: '', passwordConfirm: '',
isAdmin: Boolean(user.is_admin), isAdmin: Boolean(user.is_admin),
totpRequired: Boolean(user.totp_required),
canCreateProject: hasDirectProjectCreate(user.id) canCreateProject: hasDirectProjectCreate(user.id)
}) })
setEditError(null) setEditError(null)
@@ -219,7 +222,8 @@ export default function AdminUsersPage() {
display_name: editForm.displayName.trim(), display_name: editForm.displayName.trim(),
email: editForm.email.trim(), email: editForm.email.trim(),
password: editForm.password, password: editForm.password,
is_admin: editForm.isAdmin is_admin: editForm.isAdmin,
totp_required: editForm.totpRequired
} }
await api.updateUser(editUser.id, payload) await api.updateUser(editUser.id, payload)
if (editForm.canCreateProject && !hadProjectCreate) { if (editForm.canCreateProject && !hadProjectCreate) {
@@ -265,7 +269,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'}${u.totp_enabled ? ' · TOTP enabled' : ''}${hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}`} secondary={`${u.is_admin ? 'admin' : 'user'} · source: ${u.auth_source || 'db'}${u.totp_required ? ' · TOTP required' : ''}${u.totp_enabled ? ' · TOTP enabled' : ''}${hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}`}
sx={{ minWidth: 0, m: 0 }} sx={{ minWidth: 0, m: 0 }}
/> />
<ListRowActions> <ListRowActions>