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]
err = loadConfigFile(cfg, path)
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
+71 -9
View File
@@ -8,18 +8,31 @@ import "time"
import "codit/internal/models"
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) {
var rows *sql.Rows
var err error
var items []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 {
return nil, err
}
defer rows.Close()
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 {
return nil, err
}
@@ -36,9 +49,9 @@ func (s *Store) GetUserGroup(groupID string) (models.UserGroup, error) {
var row *sql.Row
var item models.UserGroup
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))
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
}
@@ -56,12 +69,15 @@ func (s *Store) CreateUserGroup(item models.UserGroup) (models.UserGroup, error)
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.UpdatedAt = now
_, err = s.Exec(`INSERT INTO user_groups (public_id, name, description, disabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
item.Scope = NormalizeUserGroupScope(item.Scope)
_, err = s.Exec(`INSERT INTO user_groups (public_id, name, description, disabled, totp_required, scope, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID,
strings.TrimSpace(item.Name),
strings.TrimSpace(item.Description),
item.Disabled,
item.TOTPRequired,
item.Scope,
item.CreatedAt,
item.UpdatedAt,
)
@@ -76,12 +92,15 @@ func (s *Store) UpdateUserGroup(item models.UserGroup) (models.UserGroup, error)
var err error
now = time.Now().UTC().Unix()
item.UpdatedAt = now
item.Scope = NormalizeUserGroupScope(item.Scope)
_, err = s.Exec(`UPDATE user_groups
SET name = ?, description = ?, disabled = ?, updated_at = ?
SET name = ?, description = ?, disabled = ?, totp_required = ?, scope = ?, updated_at = ?
WHERE public_id = ?`,
strings.TrimSpace(item.Name),
strings.TrimSpace(item.Description),
item.Disabled,
item.TOTPRequired,
item.Scope,
item.UpdatedAt,
strings.TrimSpace(item.ID),
)
@@ -99,6 +118,14 @@ func (s *Store) DeleteUserGroup(groupID string) error {
var tx *sql.Tx
var owned bool
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()
if err != nil {
return err
@@ -135,12 +162,24 @@ func (s *Store) ListUserGroupMembers(groupID string) ([]models.UserGroupMember,
var err error
var items []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
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 {
return nil, err
}
@@ -162,6 +201,14 @@ func (s *Store) ListUserGroupMembers(groupID string) ([]models.UserGroupMember,
func (s *Store) AddUserGroupMember(groupID string, userID string) error {
var now int64
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()
_, 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 = ?), ?)`,
@@ -171,6 +218,14 @@ func (s *Store) AddUserGroupMember(groupID string, userID string) error {
func (s *Store) RemoveUserGroupMember(groupID string, userID string) 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
WHERE group_id = (SELECT id FROM user_groups 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
(b.subject_type = 'group' AND b.subject_public_id IN (
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
JOIN users ux ON ux.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE ux.public_id = ? AND g.disabled = 0
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
))
)`,
strings.TrimSpace(projectID),
+2 -2
View File
@@ -138,7 +138,7 @@ func (s *Store) ListActivePKIClientProfilesForUser(userID string) ([]models.PKIC
AND (
(t.target_type = 'user' AND t.target_public_id = ?)
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`,
strings.TrimSpace(userID),
@@ -273,7 +273,7 @@ func (s *Store) GetActivePKIClientProfileForUser(profileID string, userID string
AND (
(t.target_type = 'user' AND t.target_public_id = ?)
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(userID),
+1 -1
View File
@@ -776,7 +776,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
AND (
(t.target_type = 'user' AND t.target_public_id = ?)
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 (
(t.target_type = 'user' AND t.target_public_id = ?)
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`,
now,
+92 -34
View File
@@ -28,9 +28,9 @@ func (s *Store) CreateUser(user models.User, passwordHash string) (models.User,
user.AuthSource = "db"
}
_, err = s.Exec(`
INSERT INTO users (public_id, username, display_name, email, password_hash, is_admin, disabled, auth_source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthSource, now, now)
INSERT INTO users (public_id, username, display_name, email, password_hash, is_admin, disabled, auth_source, totp_required, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthSource, user.TOTPRequired, now, now)
return user, err
}
@@ -41,8 +41,8 @@ func (s *Store) UpdateUser(user models.User) error {
now = time.Now().UTC()
nowUnix = now.Unix()
user.UpdatedAt = nowUnix
_, err = s.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, updated_at = ? WHERE public_id = ?`,
user.DisplayName, user.Email, user.IsAdmin, user.Disabled, now, user.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, user.TOTPRequired, now, user.ID)
return err
}
@@ -59,8 +59,8 @@ func (s *Store) UpdateUserWithPassword(user models.User, passwordHash string) er
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, password_hash = ?, updated_at = ? WHERE public_id = ?`,
user.DisplayName, user.Email, user.IsAdmin, user.Disabled, passwordHash, now, user.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, user.TOTPRequired, passwordHash, now, user.ID)
if err != nil {
rollbackIfOwned(tx, owned)
return err
@@ -84,8 +84,8 @@ func (s *Store) GetUserByID(id string) (models.User, error) {
var created time.Time
var updated time.Time
var err error
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, COALESCE(t.enabled, 0), u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.public_id = ?`, id)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &created, &updated)
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, &user.TOTPRequired, &created, &updated)
if err != nil {
return user, err
}
@@ -101,8 +101,8 @@ func (s *Store) GetUserByUsername(username string) (models.User, string, error)
var err error
var created time.Time
var updated time.Time
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, u.password_hash, COALESCE(t.enabled, 0), u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.username = ?`, username)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &user.TOTPEnabled, &created, &updated)
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, &user.TOTPRequired, &created, &updated)
if err != nil {
return user, passwordHash.String, err
}
@@ -118,13 +118,13 @@ func (s *Store) ListUsers() ([]models.User, error) {
var u models.User
var created time.Time
var updated time.Time
rows, err = s.Query(`SELECT 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 {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthSource, &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 {
return nil, err
}
@@ -846,11 +846,17 @@ func (s *Store) GetUserByAPIKeyHash(tokenHash string) (models.User, error) {
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
_, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at)
VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?)`,
mustID(), userID, token, expiresAt, time.Now().UTC())
_, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified)
VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?)`,
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
}
@@ -866,26 +872,36 @@ func (s *Store) DeleteExpiredSessions(now time.Time) error {
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 expires time.Time
var totpVerified bool
var row *sql.Row
var err error
var created time.Time
var updated time.Time
row = s.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, 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
LEFT JOIN user_totp t ON t.user_id = u.id
WHERE s.token = ? AND u.disabled = 0
`, token)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &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 {
return user, time.Time{}, err
return user, time.Time{}, false, err
}
user.CreatedAt = created.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) {
@@ -1090,10 +1106,17 @@ func (s *Store) ListProjectsForUser(userID string) ([]models.Project, error) {
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
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
JOIN users ux ON ux.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE ux.public_id = ? AND g.disabled = 0
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
(b.subject_type = 'group' AND b.subject_public_id IN (
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
JOIN users ux ON ux.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE ux.public_id = ? AND g.disabled = 0
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
(b.subject_type = 'group' AND b.subject_public_id IN (
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
JOIN users ux ON ux.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE ux.public_id = ? AND g.disabled = 0
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
))
)
) AND (p.name LIKE ? OR p.slug LIKE ?)
@@ -1465,10 +1502,17 @@ func (s *Store) CountProjectsFilteredForUser(userID string, query string) (int,
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
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
JOIN users ux ON ux.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE ux.public_id = ? AND g.disabled = 0
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
))
)
)`,
@@ -1488,10 +1532,17 @@ func (s *Store) CountProjectsFilteredForUser(userID string, query string) (int,
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
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
JOIN users ux ON ux.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE ux.public_id = ? AND g.disabled = 0
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
))
)
) AND (p.name LIKE ? OR p.slug LIKE ?)`,
@@ -1883,10 +1934,17 @@ func (s *Store) ListProjectIDsForUser(userID string) ([]string, error) {
b.subject_type = 'group'
AND b.subject_public_id IN (
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
JOIN users ux ON ux.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE ux.public_id = ? AND g.disabled = 0
WHERE gm.group_id = g.id AND ux.public_id = ?
)
)
)
)`, userID, userID)
if err != nil {
+18 -6
View File
@@ -189,11 +189,17 @@ func (s *Store) ListUserPermissions(userID string) ([]string, error) {
sp.subject_type = 'group'
AND sp.subject_public_id IN (
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
JOIN users u ON u.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE u.public_id = ?
AND g.disabled = 0
WHERE gm.group_id = g.id AND u.public_id = ?
)
)
)
)
ORDER BY sp.permission`,
@@ -234,11 +240,17 @@ func (s *Store) UserHasPermission(userID string, permission string) (bool, error
sp.subject_type = 'group'
AND sp.subject_public_id IN (
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
JOIN users u ON u.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE u.public_id = ?
AND g.disabled = 0
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
}
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 {
var err error
var now time.Time
+19 -3
View File
@@ -141,6 +141,7 @@ type createUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
TOTPRequired bool `json:"totp_required"`
}
type updateMeRequest struct {
@@ -430,6 +431,8 @@ type userGroupRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Disabled bool `json:"disabled"`
TOTPRequired bool `json:"totp_required"`
Scope string `json:"scope"`
}
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"})
}
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 err error
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)
_ = 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{
Name: api.sessionCookieName(),
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) {
var user models.User
var permissions []string
var session middleware.SessionInfo
var response meResponse
var ok bool
var err error
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()})
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) {
@@ -654,6 +664,7 @@ func (api *API) CreateUser(w http.ResponseWriter, r *http.Request, _ map[string]
DisplayName: req.DisplayName,
Email: req.Email,
IsAdmin: req.IsAdmin,
TOTPRequired: req.TOTPRequired,
AuthSource: "db",
}
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.IsAdmin = payload.IsAdmin
user.TOTPRequired = payload.TOTPRequired
newPassword = payload.Password != ""
if newPassword {
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,
Description: strings.TrimSpace(req.Description),
Disabled: req.Disabled,
TOTPRequired: req.TOTPRequired,
Scope: db.NormalizeUserGroupScope(req.Scope),
}
group, err = api.store(r).CreateUserGroup(group)
if err != nil {
@@ -820,6 +834,8 @@ func (api *API) UpdateUserGroup(w http.ResponseWriter, r *http.Request, params m
group.Name = req.Name
group.Description = strings.TrimSpace(req.Description)
group.Disabled = req.Disabled
group.TOTPRequired = req.TOTPRequired
group.Scope = db.NormalizeUserGroupScope(req.Scope)
group, err = api.store(r).UpdateUserGroup(group)
if err != nil {
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 claims oidcUserClaims
var user models.User
var required bool
var sessionVerified bool
settings, err = api.getMergedAuthSettings(r.Context())
if err != nil {
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"})
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)
http.Redirect(w, r, "/", http.StatusFound)
}
@@ -18,6 +18,10 @@ type meResponse struct {
Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"`
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"`
UpdatedAt int64 `json:"updated_at"`
Permissions []string `json:"permissions,omitempty"`
@@ -174,6 +178,10 @@ func buildMeResponse(user models.User, permissions []string) meResponse {
Disabled: user.Disabled,
AuthSource: user.AuthSource,
TOTPEnabled: user.TOTPEnabled,
TOTPRequired: user.TOTPRequired,
TOTPEffectiveRequired: user.TOTPEffectiveRequired,
TOTPSetupRequired: user.TOTPSetupRequired,
TOTPVerifyRequired: false,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Permissions: permissions,
+124 -5
View File
@@ -2,6 +2,7 @@ package handlers
import "database/sql"
import "net/http"
import "net/url"
import "strings"
import "time"
@@ -12,6 +13,9 @@ import "codit/internal/models"
type totpStatusResponse struct {
Enabled bool `json:"enabled"`
Required bool `json:"required"`
SetupRequired bool `json:"setup_required"`
VerifyRequired bool `json:"verify_required"`
}
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) {
var ok bool
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)
if err != nil {
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"})
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)
}
@@ -74,6 +91,8 @@ func (api *API) GetMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string]s
var ok bool
var item db.UserTOTP
var err error
var session middleware.SessionInfo
var sessionOK bool
ctxUser, ok = middleware.UserFromContext(r.Context())
if !ok {
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()})
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) {
@@ -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()})
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})
}
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) {
var ctxUser models.User
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()})
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) {
@@ -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()})
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) {
+85 -1
View File
@@ -1,6 +1,7 @@
package middleware
import "context"
import "encoding/json"
import "net/http"
import "strings"
import "time"
@@ -13,6 +14,12 @@ type ctxKey string
const userKey ctxKey = "user"
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 {
return WithUserCookie(store, "codit_session", next)
@@ -31,6 +38,7 @@ func WithUserCookie(store *db.Store, sessionCookieName string, next http.Handler
var ok bool
var user models.User
var expires time.Time
var totpVerified bool
var ctx context.Context
var token string
var hash string
@@ -66,7 +74,7 @@ func WithUserCookie(store *db.Store, sessionCookieName string, next http.Handler
next.ServeHTTP(w, r.WithContext(ctx))
return
}
user, expires, err = activeStore.GetSessionUser(cookie.Value)
user, expires, totpVerified, err = activeStore.GetSessionUser(cookie.Value)
if err != nil || time.Now().UTC().After(expires) {
token = apiKeyFromRequest(r)
if token == "" {
@@ -93,6 +101,7 @@ func WithUserCookie(store *db.Store, sessionCookieName string, next http.Handler
}
rememberUser(r.Context(), 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))
})
}
@@ -126,6 +135,81 @@ func PrincipalFromContext(ctx context.Context) (models.ServicePrincipal, bool) {
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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ok bool
+5
View File
@@ -9,6 +9,9 @@ type User struct {
Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"`
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"`
UpdatedAt int64 `json:"updated_at"`
}
@@ -56,6 +59,8 @@ type UserGroup struct {
Name string `json:"name"`
Description string `json:"description"`
Disabled bool `json:"disabled"`
TOTPRequired bool `json:"totp_required"`
Scope string `json:"scope"`
CreatedAt int64 `json:"created_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)
if err != nil {
err = fmt.Errorf("data dir error: %v", err)
err = fmt.Errorf("data dir error - %v", err)
goto oops
}
app.store, err = db.Open(cfg.DBDriver, cfg.DBDSN)
if err != nil {
err = fmt.Errorf("db open error: %v", err)
err = fmt.Errorf("db open error - %v", err)
goto oops
}
err = app.store.ApplyMigrationsFS(migrationFiles, "migrations")
if err != nil {
err = fmt.Errorf("migrations error: %v", err)
err = fmt.Errorf("migrations error - %v", err)
goto oops
}
err = app.store.EnsureDefaultTLSAuthPolicies()
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
}
err = app.store.EnsureTLSAuthPolicyBindings()
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
}
err = mergeTLSSettingsFromDB(cfg, app.store, EnvPrefixFromServerIdentifier(app.serverIdentifier))
if err != nil {
err = fmt.Errorf("tls settings load error: %v", err)
err = fmt.Errorf("tls settings load error - %v", err)
goto oops
}
err = bootstrapAdmin(app.store, EnvPrefixFromServerIdentifier(app.serverIdentifier))
if err != nil {
err = fmt.Errorf("bootstrap admin error: %v", err)
err = fmt.Errorf("bootstrap admin error - %v", err)
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} {
err = os.MkdirAll(dir, 0o755)
if err != nil {
err = fmt.Errorf("dir error (%s): %v", dir, err)
err = fmt.Errorf("dir error (%s) - %v", dir, err)
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)
if err != nil {
err = fmt.Errorf("git server error: %v", err)
err = fmt.Errorf("git server error - %v", err)
goto oops
}
@@ -527,7 +527,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
err = extraListenerManager.Start()
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)
extraListenerManager.Stop(shutdownCtx)
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)
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)
extraListenerManager.Stop(shutdownCtx)
app.rpm_mirror.Stop()
@@ -548,7 +548,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
}
if len(mainEndpoints) == 0 && extraListenerManager.TotalEndpointCount() == 0 {
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)
extraListenerManager.Stop(shutdownCtx)
app.rpm_mirror.Stop()
@@ -578,7 +578,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
}
cancel()
if err != nil {
app.logger.Write("", LOG_ERROR, "server error: %v", err)
app.logger.Write("", LOG_ERROR, "server error - %v", err)
return err
}
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("POST", "/api/me/totp/setup", api.SetupMyTOTP)
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("GET", "/api/users", api.ListUsers)
@@ -914,6 +915,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
// middleware.WithUser(store,
// withAPIAuth(router, auth_user, store, logger)))))
apiHandler = withAPIAuth(router, auth_user, store, logger)
apiHandler = middleware.RequireInteractiveTOTP(apiHandler)
apiHandler = middleware.WithUserCookie(store, sessionCookieNameFromServerIdentifier(app.serverIdentifier), apiHandler)
apiHandler = middleware.WithStoreTransaction(store, apiHandler)
apiHandler = middleware.AccessLog(logger, apiHandler)
@@ -1098,7 +1100,7 @@ func (m *additionalListenerManager) Start() error {
case <-m.Reload:
reconcileErr = m.reconcile()
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
auth_source?: string
totp_enabled?: boolean
totp_required?: boolean
totp_effective_required?: boolean
totp_setup_required?: boolean
totp_verify_required?: boolean
disabled?: boolean
permissions?: string[]
}
@@ -17,6 +21,9 @@ export type ServerInfo = {
export type TOTPStatus = {
enabled: boolean
required?: boolean
setup_required?: boolean
verify_required?: boolean
}
export type TOTPSetupResponse = {
@@ -30,6 +37,7 @@ export type UserCreatePayload = {
email: string
password: string
is_admin: boolean
totp_required: boolean
}
export type UserUpdatePayload = {
@@ -37,12 +45,15 @@ export type UserUpdatePayload = {
email?: string
password?: string
is_admin: boolean
totp_required: boolean
}
export type UserGroupUpsertPayload = {
name: string
description: string
disabled: boolean
totp_required: boolean
scope: 'explicit' | 'all_users'
}
export type MeUpdatePayload = {
@@ -345,6 +356,8 @@ export interface UserGroup {
name: string
description: string
disabled: boolean
totp_required: boolean
scope: 'explicit' | 'all_users'
created_at: number
updated_at: number
}
@@ -1145,6 +1158,8 @@ export const api = {
setupMyTOTP: () => request<TOTPSetupResponse>('/api/me/totp/setup', { method: 'POST' }),
enableMyTOTP: (otpCode: string) =>
request<TOTPStatus>('/api/me/totp/enable', { method: 'POST', body: JSON.stringify({ otp_code: otpCode }) }),
verifyMyTOTP: (otpCode: string) =>
request<TOTPStatus>('/api/me/totp/verify', { method: 'POST', body: JSON.stringify({ otp_code: otpCode }) }),
disableMyTOTP: (password: string, otpCode: string) =>
request<TOTPStatus>('/api/me/totp/disable', { method: 'POST', body: JSON.stringify({ password, otp_code: otpCode }) }),
listAPIKeys: () => request<APIKey[]>('/api/me/keys'),
+7 -2
View File
@@ -76,10 +76,15 @@ export default function Layout() {
useEffect(() => {
api
.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))
.finally(() => setAuthChecked(true))
}, [])
}, [location.pathname, navigate])
const navSections = useMemo(() => {
const workspaceItems: NavEntry[] = [
@@ -17,6 +17,7 @@ export type UserFormState = {
password: string
passwordConfirm: string
isAdmin: boolean
totpRequired: 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 }))} />}
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
control={<Checkbox checked={props.form.canCreateProject} onChange={(event) => props.setForm((prev) => ({ ...prev, canCreateProject: event.target.checked }))} />}
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 [totpCode, setTOTPCode] = 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 [totpError, setTOTPError] = useState<string | null>(null)
@@ -28,6 +31,9 @@ export default function AccountPage() {
setDisplayName(me.display_name || '')
setEmail(me.email || '')
setTOTPEnabled(Boolean(me.totp_enabled))
setTOTPRequired(Boolean(me.totp_effective_required))
setTOTPSetupRequired(Boolean(me.totp_setup_required))
setTOTPVerifyRequired(Boolean(me.totp_verify_required))
})
.catch(() => {
setUser(null)
@@ -36,6 +42,9 @@ export default function AccountPage() {
.getMyTOTP()
.then((status) => {
setTOTPEnabled(Boolean(status.enabled))
setTOTPRequired(Boolean(status.required))
setTOTPSetupRequired(Boolean(status.setup_required))
setTOTPVerifyRequired(Boolean(status.verify_required))
})
.catch(() => {
setTOTPEnabled(false)
@@ -94,6 +103,8 @@ export default function AccountPage() {
try {
await api.enableMyTOTP(totpCode.trim())
setTOTPEnabled(true)
setTOTPSetupRequired(false)
setTOTPVerifyRequired(false)
setTOTPSetup(null)
setTOTPCode('')
} 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 () => {
let message: string
setTOTPBusy(true)
@@ -170,9 +197,23 @@ export default function AccountPage() {
</Typography>
{totpError ? <Alert severity="error" sx={{ mb: 1 }}>{totpError}</Alert> : null}
<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>
{!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 }}>
<Button variant="outlined" onClick={handleSetupTOTP} disabled={totpBusy}>
Start TOTP Setup
+5 -4
View File
@@ -67,9 +67,10 @@ export default function AdminSSHSessionsPage() {
setLoading(true)
setError(null)
try {
nextQuery = query.trim()
;[response, profileList, serverList] = await Promise.all([
nextQuery = query.trim(); // note this semicolon is super important. otherwide trim() joins with [..] on the next line
[response, profileList, serverList] = await Promise.all([
api.listSSHSessionsAdmin({
limit,
offset,
@@ -81,10 +82,10 @@ export default function AdminSSHSessionsPage() {
])
profileMap = {}
serverMap = {}
for (i = 0; i < profileList.length; i++) {
for (i = 0; i < (profileList?.length ?? 0); 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]
}
setItems(Array.isArray(response.items) ? response.items : [])
+37 -5
View File
@@ -56,6 +56,8 @@ export default function AdminUserGroupsPage() {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
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 [deleteConfirm, setDeleteConfirm] = useState('')
@@ -149,6 +151,8 @@ export default function AdminUserGroupsPage() {
setName('')
setDescription('')
setDisabled(false)
setTOTPRequired(false)
setScope('explicit')
setEditOpen(true)
}
@@ -158,6 +162,8 @@ export default function AdminUserGroupsPage() {
setName(item.name)
setDescription(item.description || '')
setDisabled(item.disabled)
setTOTPRequired(Boolean(item.totp_required))
setScope(item.scope === 'all_users' ? 'all_users' : 'explicit')
setEditOpen(true)
}
@@ -167,7 +173,7 @@ export default function AdminUserGroupsPage() {
setDialogError(null)
setError(null)
try {
payload = { name: name.trim(), description: description.trim(), disabled }
payload = { name: name.trim(), description: description.trim(), disabled, totp_required: totpRequired, scope }
if (editID) {
await api.updateUserGroup(editID, payload)
} else {
@@ -321,7 +327,7 @@ export default function AdminUserGroupsPage() {
<ListItemText
sx={{ minWidth: 0, flex: 1 }}
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={{
noWrap: true,
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.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 }}>
<IconButton
size="small"
@@ -349,6 +356,7 @@ export default function AdminUserGroupsPage() {
setDeleteItem(group)
setDeleteConfirm('')
}}
disabled={group.scope === 'all_users'}
>
<DeleteIcon fontSize="small" />
</IconButton>
@@ -375,10 +383,16 @@ export default function AdminUserGroupsPage() {
{hasDirectProjectCreate(selectedGroup.id) ? (
<Chip size="small" variant="outlined" label="project.create" />
) : 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>
<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>
{selectedGroup.description ? (
<Typography variant="body2" color="text.secondary">
@@ -400,7 +414,7 @@ export default function AdminUserGroupsPage() {
<SectionCard
title="Members"
actions={
selectedGroup ? (
selectedGroup && selectedGroup.scope !== 'all_users' ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<SelectField
select
@@ -442,12 +456,17 @@ export default function AdminUserGroupsPage() {
setDeleteMemberItem(member)
setDeleteMemberConfirm('')
}}
disabled={busy}
disabled={busy || selectedGroup.scope === 'all_users'}
>
Delete
</Button>
</Box>
))}
{selectedGroup.scope === 'all_users' ? (
<Typography variant="body2" color="text.secondary">
This virtual group includes all active interactive users.
</Typography>
) : null}
{members.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No members.
@@ -495,10 +514,23 @@ export default function AdminUserGroupsPage() {
multiline
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
control={<Checkbox checked={disabled} onChange={(event) => setDisabled(event.target.checked)} />}
label="Disabled"
/>
<FormControlLabel
control={<Checkbox checked={totpRequired} onChange={(event) => setTOTPRequired(event.target.checked)} />}
label="Require TOTP for members"
/>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={saveGroup} disabled={busy || !name.trim()}>
+7 -3
View File
@@ -25,6 +25,7 @@ function createEmptyForm(): UserFormState {
password: '',
passwordConfirm: '',
isAdmin: false,
totpRequired: false,
canCreateProject: false
}
}
@@ -90,7 +91,8 @@ export default function AdminUsersPage() {
display_name: createForm.displayName.trim(),
email: createForm.email.trim(),
password: createForm.password,
is_admin: createForm.isAdmin
is_admin: createForm.isAdmin,
totp_required: createForm.totpRequired
}
if (createForm.password !== createForm.passwordConfirm) {
setCreateError('Password and confirmation must match.')
@@ -189,6 +191,7 @@ export default function AdminUsersPage() {
password: '',
passwordConfirm: '',
isAdmin: Boolean(user.is_admin),
totpRequired: Boolean(user.totp_required),
canCreateProject: hasDirectProjectCreate(user.id)
})
setEditError(null)
@@ -219,7 +222,8 @@ export default function AdminUsersPage() {
display_name: editForm.displayName.trim(),
email: editForm.email.trim(),
password: editForm.password,
is_admin: editForm.isAdmin
is_admin: editForm.isAdmin,
totp_required: editForm.totpRequired
}
await api.updateUser(editUser.id, payload)
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 }}>
<ListItemText
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 }}
/>
<ListRowActions>