Compare commits
3 Commits
d3a0f96b55
...
16e8d5b72d
| Author | SHA1 | Date | |
|---|---|---|---|
| 16e8d5b72d | |||
| 8e7c5e1d44 | |||
| aa70458d15 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
var group models.UserGroup
|
||||||
|
group, err = s.GetUserGroup(groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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
|
rows, err = s.Query(`SELECT g.public_id, u.public_id, u.username, u.display_name, m.created_at
|
||||||
FROM user_group_members m
|
FROM user_group_members m
|
||||||
JOIN user_groups g ON g.id = m.group_id
|
JOIN user_groups g ON g.id = m.group_id
|
||||||
JOIN users u ON u.id = m.user_id
|
JOIN users u ON u.id = m.user_id
|
||||||
WHERE g.public_id = ?
|
WHERE g.public_id = ?
|
||||||
ORDER BY u.username`, strings.TrimSpace(groupID))
|
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_groups g
|
||||||
|
WHERE g.disabled = 0
|
||||||
|
AND (
|
||||||
|
g.scope = 'all_users'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
FROM user_group_members gm
|
FROM user_group_members gm
|
||||||
JOIN users ux ON ux.id = gm.user_id
|
JOIN users ux ON ux.id = gm.user_id
|
||||||
JOIN user_groups g ON g.id = gm.group_id
|
WHERE gm.group_id = g.id AND ux.public_id = ?
|
||||||
WHERE ux.public_id = ? AND g.disabled = 0
|
)
|
||||||
|
)
|
||||||
))
|
))
|
||||||
)`,
|
)`,
|
||||||
strings.TrimSpace(projectID),
|
strings.TrimSpace(projectID),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 = ?))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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_groups g
|
||||||
|
WHERE g.disabled = 0
|
||||||
|
AND (
|
||||||
|
g.scope = 'all_users'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
FROM user_group_members gm
|
FROM user_group_members gm
|
||||||
JOIN users ux ON ux.id = gm.user_id
|
JOIN users ux ON ux.id = gm.user_id
|
||||||
JOIN user_groups g ON g.id = gm.group_id
|
WHERE gm.group_id = g.id AND ux.public_id = ?
|
||||||
WHERE ux.public_id = ? AND g.disabled = 0
|
)
|
||||||
|
)
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -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_groups g
|
||||||
|
WHERE g.disabled = 0
|
||||||
|
AND (
|
||||||
|
g.scope = 'all_users'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
FROM user_group_members gm
|
FROM user_group_members gm
|
||||||
JOIN users ux ON ux.id = gm.user_id
|
JOIN users ux ON ux.id = gm.user_id
|
||||||
JOIN user_groups g ON g.id = gm.group_id
|
WHERE gm.group_id = g.id AND ux.public_id = ?
|
||||||
WHERE ux.public_id = ? AND g.disabled = 0
|
)
|
||||||
|
)
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -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_groups g
|
||||||
|
WHERE g.disabled = 0
|
||||||
|
AND (
|
||||||
|
g.scope = 'all_users'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
FROM user_group_members gm
|
FROM user_group_members gm
|
||||||
JOIN users ux ON ux.id = gm.user_id
|
JOIN users ux ON ux.id = gm.user_id
|
||||||
JOIN user_groups g ON g.id = gm.group_id
|
WHERE gm.group_id = g.id AND ux.public_id = ?
|
||||||
WHERE ux.public_id = ? AND g.disabled = 0
|
)
|
||||||
|
)
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
) 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_groups g
|
||||||
|
WHERE g.disabled = 0
|
||||||
|
AND (
|
||||||
|
g.scope = 'all_users'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
FROM user_group_members gm
|
FROM user_group_members gm
|
||||||
JOIN users ux ON ux.id = gm.user_id
|
JOIN users ux ON ux.id = gm.user_id
|
||||||
JOIN user_groups g ON g.id = gm.group_id
|
WHERE gm.group_id = g.id AND ux.public_id = ?
|
||||||
WHERE ux.public_id = ? AND g.disabled = 0
|
)
|
||||||
|
)
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
)`,
|
)`,
|
||||||
@@ -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_groups g
|
||||||
|
WHERE g.disabled = 0
|
||||||
|
AND (
|
||||||
|
g.scope = 'all_users'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
FROM user_group_members gm
|
FROM user_group_members gm
|
||||||
JOIN users ux ON ux.id = gm.user_id
|
JOIN users ux ON ux.id = gm.user_id
|
||||||
JOIN user_groups g ON g.id = gm.group_id
|
WHERE gm.group_id = g.id AND ux.public_id = ?
|
||||||
WHERE ux.public_id = ? AND g.disabled = 0
|
)
|
||||||
|
)
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
) 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_groups g
|
||||||
|
WHERE g.disabled = 0
|
||||||
|
AND (
|
||||||
|
g.scope = 'all_users'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
FROM user_group_members gm
|
FROM user_group_members gm
|
||||||
JOIN users ux ON ux.id = gm.user_id
|
JOIN users ux ON ux.id = gm.user_id
|
||||||
JOIN user_groups g ON g.id = gm.group_id
|
WHERE gm.group_id = g.id AND ux.public_id = ?
|
||||||
WHERE ux.public_id = ? AND g.disabled = 0
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)`, userID, userID)
|
)`, userID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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_groups g
|
||||||
|
WHERE g.disabled = 0
|
||||||
|
AND (
|
||||||
|
g.scope = 'all_users'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
FROM user_group_members gm
|
FROM user_group_members gm
|
||||||
JOIN users u ON u.id = gm.user_id
|
JOIN users u ON u.id = gm.user_id
|
||||||
JOIN user_groups g ON g.id = gm.group_id
|
WHERE gm.group_id = g.id AND u.public_id = ?
|
||||||
WHERE u.public_id = ?
|
)
|
||||||
AND g.disabled = 0
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
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_groups g
|
||||||
|
WHERE g.disabled = 0
|
||||||
|
AND (
|
||||||
|
g.scope = 'all_users'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
FROM user_group_members gm
|
FROM user_group_members gm
|
||||||
JOIN users u ON u.id = gm.user_id
|
JOIN users u ON u.id = gm.user_id
|
||||||
JOIN user_groups g ON g.id = gm.group_id
|
WHERE gm.group_id = g.id AND u.public_id = ?
|
||||||
WHERE u.public_id = ?
|
)
|
||||||
AND g.disabled = 0
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 : [])
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user