feat(auth): add local and OIDC management to tht
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
package authconfig
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
"golang.org/x/term"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const maxPasswordFileBytes int64 = 1025
|
||||
|
||||
var errCommandRefused = errors.New("authentication command refused")
|
||||
|
||||
// Run implements the host-only authentication operator surface. It accepts password bytes only
|
||||
// from an echo-free terminal or a bounded private file, and never writes them to either stream.
|
||||
func Run(ctx context.Context, installation config.Installation, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
_ = ctx
|
||||
if len(args) == 0 {
|
||||
return authFailure(stderr, "auth requires a subcommand")
|
||||
}
|
||||
directory := installation.AuthenticationDirectory()
|
||||
switch args[0] {
|
||||
case "configure":
|
||||
if err := configure(directory, args[1:], stdin, stderr); err != nil {
|
||||
return authFailure(stderr, authMessage(err))
|
||||
}
|
||||
return 0
|
||||
case "status":
|
||||
if err := status(directory, args[1:], stdout); err != nil {
|
||||
return authFailure(stderr, authMessage(err))
|
||||
}
|
||||
return 0
|
||||
case "user":
|
||||
if err := user(directory, args[1:], stdin, stdout, stderr); err != nil {
|
||||
return authFailure(stderr, authMessage(err))
|
||||
}
|
||||
return 0
|
||||
case "check":
|
||||
return authFailure(stderr, "auth check is not available in this release")
|
||||
default:
|
||||
return authFailure(stderr, "unknown auth subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
func authFailure(stderr io.Writer, message string) int {
|
||||
fmt.Fprintf(stderr, "tht: %s\n", message)
|
||||
return 2
|
||||
}
|
||||
|
||||
func authMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, errPasswordFileRequired):
|
||||
return "--password-file is required when standard input is not an interactive terminal"
|
||||
case errors.Is(err, errExistingConfiguration):
|
||||
return "authentication is already configured; refusing to overwrite it"
|
||||
case errors.Is(err, errOIDCUsers):
|
||||
return "local user commands are unavailable while OIDC authentication is configured"
|
||||
case errors.Is(err, errLastAdministrator):
|
||||
return "refusing to remove or disable the last enabled administrator"
|
||||
case errors.Is(err, errAmbiguousGroups):
|
||||
return "OIDC user and administrator groups must be different"
|
||||
case errors.Is(err, errCommandRefused):
|
||||
return "authentication command refused"
|
||||
default:
|
||||
return "authentication configuration is unavailable or invalid"
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
errPasswordFileRequired = errors.New("password file required")
|
||||
errExistingConfiguration = errors.New("existing configuration")
|
||||
errOIDCUsers = errors.New("OIDC user commands")
|
||||
errLastAdministrator = errors.New("last administrator")
|
||||
errAmbiguousGroups = errors.New("ambiguous groups")
|
||||
)
|
||||
|
||||
type configureRequest struct {
|
||||
mode, publicURL, adminUser, adminDisplayName, passwordFile string
|
||||
issuer, clientID, authentikBaseURL, userGroup, adminGroup string
|
||||
}
|
||||
|
||||
func configure(directory string, args []string, stdin io.Reader, stderr io.Writer) error {
|
||||
request, err := parseConfigure(args)
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
if err := safeio.EnsurePrivateDirectory(directory); err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
if _, _, err := Load(directory); err == nil {
|
||||
return errExistingConfiguration
|
||||
}
|
||||
for _, name := range []string{authFileName, usersFileName} {
|
||||
if _, err := os.Lstat(filepath.Join(directory, name)); !errors.Is(err, os.ErrNotExist) {
|
||||
return errCommandRefused
|
||||
}
|
||||
}
|
||||
if request.mode == "oidc" {
|
||||
if request.userGroup == request.adminGroup {
|
||||
return errAmbiguousGroups
|
||||
}
|
||||
return writeInitialOIDC(directory, request)
|
||||
}
|
||||
if request.adminUser == "" {
|
||||
if err := promptLocalConfigure(&request, stdin, stderr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
password, err := readPassword(request.passwordFile, stdin, stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zero(password)
|
||||
hash, err := HashPassword(password, nil)
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
initialUser, err := newUser(request.adminUser, request.adminDisplayName, hash, []Role{RoleAdmin})
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
return writeInitialLocal(directory, request.publicURL, initialUser)
|
||||
}
|
||||
|
||||
func parseConfigure(args []string) (configureRequest, error) {
|
||||
request := configureRequest{}
|
||||
values := map[string]*string{
|
||||
"--mode": &request.mode, "--public-url": &request.publicURL, "--admin-user": &request.adminUser,
|
||||
"--admin-display-name": &request.adminDisplayName, "--password-file": &request.passwordFile,
|
||||
"--issuer": &request.issuer, "--client-id": &request.clientID, "--authentik-base-url": &request.authentikBaseURL,
|
||||
"--user-group": &request.userGroup, "--admin-group": &request.adminGroup,
|
||||
}
|
||||
for len(args) > 0 {
|
||||
option := args[0]
|
||||
args = args[1:]
|
||||
target, exists := values[option]
|
||||
if !exists || len(args) == 0 || *target != "" {
|
||||
return configureRequest{}, errCommandRefused
|
||||
}
|
||||
*target, args = args[0], args[1:]
|
||||
}
|
||||
if request.mode != "local" && request.mode != "oidc" || !validPublicURL(request.publicURL) {
|
||||
return configureRequest{}, errCommandRefused
|
||||
}
|
||||
if request.mode == "local" {
|
||||
if request.issuer != "" || request.clientID != "" || request.authentikBaseURL != "" || request.userGroup != "" || request.adminGroup != "" {
|
||||
return configureRequest{}, errCommandRefused
|
||||
}
|
||||
if request.adminUser != "" && request.passwordFile == "" { // TTY supplies the password interactively.
|
||||
return request, nil
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
if request.adminUser != "" || request.adminDisplayName != "" || request.passwordFile != "" || request.issuer == "" || request.clientID == "" || request.authentikBaseURL == "" || request.userGroup == "" || request.adminGroup == "" || !validOIDCURL(request.issuer) || !validHTTPSURL(request.authentikBaseURL) || (!isHTTPS(request.publicURL) && !isLoopbackURL(request.publicURL)) {
|
||||
return configureRequest{}, errCommandRefused
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func promptLocalConfigure(request *configureRequest, stdin io.Reader, stderr io.Writer) error {
|
||||
file, ok := stdin.(*os.File)
|
||||
if !ok || !term.IsTerminal(int(file.Fd())) {
|
||||
return errPasswordFileRequired
|
||||
}
|
||||
reader := bufio.NewReader(file)
|
||||
username, err := promptLine(reader, stderr, "Initial administrator username: ")
|
||||
if err != nil || username == "" {
|
||||
return errCommandRefused
|
||||
}
|
||||
displayName, err := promptLine(reader, stderr, "Initial administrator display name (optional): ")
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
request.adminUser, request.adminDisplayName = username, displayName
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptLine(reader *bufio.Reader, stderr io.Writer, prompt string) (string, error) {
|
||||
fmt.Fprint(stderr, prompt)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
|
||||
func writeInitialLocal(directory, publicURL string, user User) error {
|
||||
registryBytes, err := yaml.Marshal(Registry{Version: 1, Users: []User{user}})
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
configBytes, err := yaml.Marshal(Config{Version: 1, Mode: "local", PublicURL: publicURL, Session: defaultSession(), Local: LocalConfig{UsersFile: usersFileName}})
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, usersFileName), append(registryBytes, '\n'), 0o600); err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, authFileName), append(configBytes, '\n'), 0o600); err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeInitialOIDC(directory string, request configureRequest) error {
|
||||
configuration := Config{
|
||||
Version: 1, Mode: "oidc", PublicURL: request.publicURL, Session: defaultSession(),
|
||||
OIDC: OIDCConfig{Issuer: request.issuer, ClientID: request.clientID, ClientSecretRef: "THT_OIDC_CLIENT_SECRET", Scopes: []string{"openid", "profile", "email"}, GroupsClaim: "groups"},
|
||||
GroupCatalog: GroupCatalogConfig{Driver: "authentik", BaseURL: request.authentikBaseURL, APITokenRef: "THT_AUTHENTIK_API_TOKEN"},
|
||||
Authorization: AuthorizationConfig{GroupRoles: map[string][]Role{request.userGroup: {RoleUser}, request.adminGroup: {RoleAdmin}}},
|
||||
}
|
||||
contents, err := yaml.Marshal(configuration)
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, authFileName), append(contents, '\n'), 0o600); err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultSession() SessionConfig {
|
||||
return SessionConfig{RegularTTLSeconds: 43200, RegularIdleSeconds: 7200, RememberTTLSeconds: 2592000, RememberIdleSeconds: 604800, OIDCTTLSeconds: 28800}
|
||||
}
|
||||
|
||||
func readPassword(path string, stdin io.Reader, stderr io.Writer) ([]byte, error) {
|
||||
if path != "" {
|
||||
if err := safeio.ValidatePrivateRegular(path); err != nil {
|
||||
return nil, errCommandRefused
|
||||
}
|
||||
password, err := safeio.ReadCanonicalRegular(path, maxPasswordFileBytes)
|
||||
if err != nil || len(password) > passwordMaxBytes+1 {
|
||||
return nil, errCommandRefused
|
||||
}
|
||||
password = trimOneNewline(password)
|
||||
return password, validatePasswordInput(password)
|
||||
}
|
||||
file, ok := stdin.(*os.File)
|
||||
if !ok || !term.IsTerminal(int(file.Fd())) {
|
||||
return nil, errPasswordFileRequired
|
||||
}
|
||||
fmt.Fprint(stderr, "Password: ")
|
||||
password, err := term.ReadPassword(int(file.Fd()))
|
||||
fmt.Fprintln(stderr)
|
||||
if err != nil {
|
||||
return nil, errCommandRefused
|
||||
}
|
||||
fmt.Fprint(stderr, "Confirm password: ")
|
||||
confirmation, err := term.ReadPassword(int(file.Fd()))
|
||||
fmt.Fprintln(stderr)
|
||||
if err != nil {
|
||||
zero(password)
|
||||
return nil, errCommandRefused
|
||||
}
|
||||
matched := string(password) == string(confirmation)
|
||||
zero(confirmation)
|
||||
if !matched {
|
||||
zero(password)
|
||||
return nil, errCommandRefused
|
||||
}
|
||||
return password, validatePasswordInput(password)
|
||||
}
|
||||
|
||||
func trimOneNewline(value []byte) []byte {
|
||||
if bytes.HasSuffix(value, []byte("\r\n")) {
|
||||
return value[:len(value)-2]
|
||||
}
|
||||
if bytes.HasSuffix(value, []byte("\n")) {
|
||||
return value[:len(value)-1]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validatePasswordInput(password []byte) error {
|
||||
if !validPassword(password) {
|
||||
return errCommandRefused
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func zero(value []byte) {
|
||||
for index := range value {
|
||||
value[index] = 0
|
||||
}
|
||||
}
|
||||
|
||||
type statusResult struct {
|
||||
Mode string `json:"mode"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
UserCounts map[string]int `json:"userCounts"`
|
||||
Revision string `json:"configRevision"`
|
||||
}
|
||||
|
||||
func status(directory string, args []string, stdout io.Writer) error {
|
||||
jsonMode := len(args) == 1 && args[0] == "--json"
|
||||
if len(args) != 0 && !jsonMode {
|
||||
return errCommandRefused
|
||||
}
|
||||
configuration, registry, err := Load(directory)
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
counts := map[string]int{"user": 0, "admin": 0}
|
||||
for _, user := range registry.Users {
|
||||
for _, role := range user.Roles {
|
||||
counts[string(role)]++
|
||||
}
|
||||
}
|
||||
revision, err := configRevision(directory, configuration.Mode)
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
result := statusResult{Mode: configuration.Mode, PublicURL: configuration.PublicURL, UserCounts: counts, Revision: revision}
|
||||
if jsonMode {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
_, err = fmt.Fprintf(stdout, "Authentication mode: %s\nPublic URL: %s\nUsers: user=%d admin=%d\nConfig revision: %s\n", result.Mode, result.PublicURL, counts["user"], counts["admin"], result.Revision)
|
||||
return err
|
||||
}
|
||||
|
||||
func configRevision(directory, mode string) (string, error) {
|
||||
auth, err := safeio.ReadCanonicalRegular(filepath.Join(directory, authFileName), maxYAMLBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.New()
|
||||
_, _ = sum.Write(auth)
|
||||
if mode == "local" {
|
||||
users, err := safeio.ReadCanonicalRegular(filepath.Join(directory, usersFileName), maxYAMLBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, _ = sum.Write(users)
|
||||
}
|
||||
return "sha256:" + hex.EncodeToString(sum.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func user(directory string, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errCommandRefused
|
||||
}
|
||||
configuration, _, err := Load(directory)
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
if configuration.Mode != "local" {
|
||||
return errOIDCUsers
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return listUsers(directory, args[1:], stdout)
|
||||
case "add":
|
||||
return addUser(directory, args[1:], stdin, stderr)
|
||||
case "set-password":
|
||||
return setPassword(directory, args[1:], stdin, stderr)
|
||||
case "enable", "disable":
|
||||
return setEnabled(directory, args[0] == "enable", args[1:])
|
||||
case "grant", "revoke":
|
||||
return changeRole(directory, args[0] == "grant", args[1:])
|
||||
case "logout-all":
|
||||
return logoutAll(directory, args[1:])
|
||||
default:
|
||||
return errCommandRefused
|
||||
}
|
||||
}
|
||||
|
||||
func listUsers(directory string, args []string, stdout io.Writer) error {
|
||||
jsonMode := len(args) == 1 && args[0] == "--json"
|
||||
if len(args) != 0 && !jsonMode {
|
||||
return errCommandRefused
|
||||
}
|
||||
_, registry, err := Load(directory)
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
if jsonMode {
|
||||
return json.NewEncoder(stdout).Encode(struct {
|
||||
Users []User `json:"users"`
|
||||
}{Users: registry.Users})
|
||||
}
|
||||
for _, item := range registry.Users {
|
||||
if _, err := fmt.Fprintf(stdout, "%s\t%s\tenabled=%t\troles=%s\n", item.Username, item.DisplayName, item.Enabled, strings.Join(rolesToStrings(item.Roles), ",")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addUser(directory string, args []string, stdin io.Reader, stderr io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errCommandRefused
|
||||
}
|
||||
username, request, err := parseUserRolePassword(args)
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
password, err := readPassword(request.passwordFile, stdin, stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zero(password)
|
||||
hash, err := HashPassword(password, nil)
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
return MutateUsers(directory, func(registry *Registry) error {
|
||||
if registry.FindByUsername(username) != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
item, err := newUser(username, request.displayName, hash, []Role{request.role})
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
registry.Users = append(registry.Users, item)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type userRequest struct {
|
||||
role Role
|
||||
displayName, passwordFile string
|
||||
}
|
||||
|
||||
func parseUserRolePassword(args []string) (string, userRequest, error) {
|
||||
username := args[0]
|
||||
args = args[1:]
|
||||
request := userRequest{}
|
||||
for len(args) > 0 {
|
||||
option := args[0]
|
||||
args = args[1:]
|
||||
if len(args) == 0 {
|
||||
return "", userRequest{}, errCommandRefused
|
||||
}
|
||||
value := args[0]
|
||||
args = args[1:]
|
||||
switch option {
|
||||
case "--role":
|
||||
if request.role != "" {
|
||||
return "", userRequest{}, errCommandRefused
|
||||
}
|
||||
request.role = Role(value)
|
||||
case "--display-name":
|
||||
if request.displayName != "" {
|
||||
return "", userRequest{}, errCommandRefused
|
||||
}
|
||||
request.displayName = value
|
||||
case "--password-file":
|
||||
if request.passwordFile != "" {
|
||||
return "", userRequest{}, errCommandRefused
|
||||
}
|
||||
request.passwordFile = value
|
||||
default:
|
||||
return "", userRequest{}, errCommandRefused
|
||||
}
|
||||
}
|
||||
if !usernamePattern.MatchString(username) || (request.role != RoleUser && request.role != RoleAdmin) {
|
||||
return "", userRequest{}, errCommandRefused
|
||||
}
|
||||
return username, request, nil
|
||||
}
|
||||
|
||||
func setPassword(directory string, args []string, stdin io.Reader, stderr io.Writer) error {
|
||||
if len(args) < 1 || !usernamePattern.MatchString(args[0]) {
|
||||
return errCommandRefused
|
||||
}
|
||||
username := args[0]
|
||||
passwordFile, err := parsePasswordFile(args[1:])
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
password, err := readPassword(passwordFile, stdin, stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zero(password)
|
||||
hash, err := HashPassword(password, nil)
|
||||
if err != nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
return MutateUsers(directory, func(registry *Registry) error {
|
||||
item := registry.FindByUsername(username)
|
||||
if item == nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
item.PasswordHash = hash
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func parsePasswordFile(args []string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if len(args) != 2 || args[0] != "--password-file" || args[1] == "" {
|
||||
return "", errCommandRefused
|
||||
}
|
||||
return args[1], nil
|
||||
}
|
||||
|
||||
func setEnabled(directory string, enabled bool, args []string) error {
|
||||
if len(args) != 1 || !usernamePattern.MatchString(args[0]) {
|
||||
return errCommandRefused
|
||||
}
|
||||
return MutateUsers(directory, func(registry *Registry) error {
|
||||
item := registry.FindByUsername(args[0])
|
||||
if item == nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
if !enabled && item.Enabled && hasRole(item.Roles, RoleAdmin) && enabledAdminCount(*registry) == 1 {
|
||||
return errLastAdministrator
|
||||
}
|
||||
item.Enabled = enabled
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func changeRole(directory string, grant bool, args []string) error {
|
||||
if len(args) != 3 || !usernamePattern.MatchString(args[0]) || args[1] != "--role" || (args[2] != string(RoleUser) && args[2] != string(RoleAdmin)) {
|
||||
return errCommandRefused
|
||||
}
|
||||
role := Role(args[2])
|
||||
return MutateUsers(directory, func(registry *Registry) error {
|
||||
item := registry.FindByUsername(args[0])
|
||||
if item == nil {
|
||||
return errCommandRefused
|
||||
}
|
||||
if grant {
|
||||
if hasRole(item.Roles, role) {
|
||||
return errCommandRefused
|
||||
}
|
||||
item.Roles = append(item.Roles, role)
|
||||
return nil
|
||||
}
|
||||
if !hasRole(item.Roles, role) || len(item.Roles) == 1 {
|
||||
return errCommandRefused
|
||||
}
|
||||
if role == RoleAdmin && item.Enabled && enabledAdminCount(*registry) == 1 {
|
||||
return errLastAdministrator
|
||||
}
|
||||
item.Roles = withoutRole(item.Roles, role)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func logoutAll(directory string, args []string) error {
|
||||
if len(args) != 2 || !usernamePattern.MatchString(args[0]) || args[1] != "--yes" {
|
||||
return errCommandRefused
|
||||
}
|
||||
return MutateUsers(directory, func(registry *Registry) error {
|
||||
item := registry.FindByUsername(args[0])
|
||||
if item == nil || item.AuthRevision == ^uint64(0) {
|
||||
return errCommandRefused
|
||||
}
|
||||
item.AuthRevision++
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func enabledAdminCount(registry Registry) int {
|
||||
count := 0
|
||||
for _, item := range registry.Users {
|
||||
if item.Enabled && hasRole(item.Roles, RoleAdmin) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func withoutRole(roles []Role, unwanted Role) []Role {
|
||||
result := make([]Role, 0, len(roles)-1)
|
||||
for _, role := range roles {
|
||||
if role != unwanted {
|
||||
result = append(result, role)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rolesToStrings(roles []Role) []string {
|
||||
values := make([]string, len(roles))
|
||||
for index, role := range roles {
|
||||
values[index] = string(role)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func validPublicURL(value string) bool {
|
||||
parsed, err := url.Parse(value)
|
||||
return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" && parsed.User == nil && parsed.Path == "" && parsed.RawQuery == "" && parsed.Fragment == ""
|
||||
}
|
||||
|
||||
func validOIDCURL(value string) bool {
|
||||
parsed, err := url.Parse(value)
|
||||
return err == nil && parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == ""
|
||||
}
|
||||
|
||||
func validHTTPSURL(value string) bool {
|
||||
return validOIDCURL(value) && strings.TrimSuffix(value, "/") == value
|
||||
}
|
||||
func isHTTPS(value string) bool {
|
||||
parsed, err := url.Parse(value)
|
||||
return err == nil && parsed.Scheme == "https"
|
||||
}
|
||||
func isLoopbackURL(value string) bool {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
return host == "localhost" || net.ParseIP(host) != nil && net.ParseIP(host).IsLoopback()
|
||||
}
|
||||
Reference in New Issue
Block a user