diff --git a/.drone.jsonnet b/.drone.jsonnet index 23371dc32..1e119434a 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -4,7 +4,7 @@ local selenium = '4.35.0-20250828'; local go = '1.24.0'; local node = '22.16.0'; local deployer = 'https://github.com/syncloud/store/releases/download/4/syncloud-release'; -local authelia = '4.38.8'; +local authelia = '4.39.15'; local distro_default = 'buster'; local distros = ['bookworm', 'buster']; local bootstrap = '25.02'; @@ -48,17 +48,19 @@ local build(arch, testUI) = [{ name: 'authelia', image: 'authelia/authelia:' + authelia, commands: [ - './authelia/package.sh', + './authelia/build.sh', ], - }, + ] + [ { - name: 'authelia test', - image: 'debian:bookworm-slim', + name: 'authelia test ' + distro, + image: 'syncloud/bootstrap-' + distro + '-' + arch + ':' + bootstrap, commands: [ './authelia/test.sh', ], - }, + } + for distro in distros + ] + [ { name: 'build web', image: 'node:' + node, diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..399a32891 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# Debugging CI failures + +When a CI build fails, always start by identifying the failing step: +``` +curl -s "http://ci.syncloud.org:8080/api/repos/syncloud/platform/builds/{N}" | python3 -c " +import json,sys +b=json.load(sys.stdin) +for stage in b.get('stages',[]): + for step in stage.get('steps',[]): + if step.get('status') == 'failure': + print(step.get('name'), '-', step.get('status')) +" +``` + +Then get the step log (stage=pipeline index, step=step number): +``` +curl -s "http://ci.syncloud.org:8080/api/repos/syncloud/platform/builds/{N}/logs/{stage}/{step}" | python3 -c " +import json,sys; [print(l.get('out',''), end='') for l in json.load(sys.stdin)] +" | tail -80 +``` + +# CI + +http://ci.syncloud.org:8080/syncloud/platform + +CI is Drone CI (JS SPA). Check builds via API: +``` +curl -s "http://ci.syncloud.org:8080/api/repos/syncloud/platform/builds?limit=5" +``` + +## CI Artifacts + +Artifacts are served at `http://ci.syncloud.org:8081` (returns JSON directory listings). + +Browse the top level for a build (returns distro subdirs + snap file): +``` +curl -s "http://ci.syncloud.org:8081/files/platform/{build}-{arch}/" +``` + +Each distro dir contains `app/`, `platform/`, and for upgrade/UI tests also `desktop/`, `refresh.journalctl.log`, `video.mkv`: +``` +curl -s "http://ci.syncloud.org:8081/files/platform/{build}-{arch}/{distro}/" +curl -s "http://ci.syncloud.org:8081/files/platform/{build}-{arch}/{distro}/app/" +curl -s "http://ci.syncloud.org:8081/files/platform/{build}-{arch}/{distro}/desktop/" +``` + +Directory structure: +``` +{build}-{arch}/ + {distro}/ + app/ + journalctl.log # full journal from integration test teardown + ps.log, netstat.log # process/network state at teardown + platform/ # platform logs + desktop/ # UI test artifacts (amd64 only) + journalctl.log + screenshot/ + {test-name}.png + {test-name}.html.log + log/ + refresh.journalctl.log # full journal from upgrade test (pre/post-refresh) + video.mkv # selenium recording +``` + +Download a file directly: +``` +curl -O "http://ci.syncloud.org:8081/files/platform/282-amd64/bookworm/refresh.journalctl.log" +curl -O "http://ci.syncloud.org:8081/files/platform/282-amd64/bookworm/app/journalctl.log" +curl -O "http://ci.syncloud.org:8081/files/platform/282-amd64/bookworm/desktop/journalctl.log" +``` + +# Project Structure + +- **Snap-based platform** providing self-hosting OS, app installer, and platform services for Syncloud +- Architectures: amd64, arm64, arm +- Distros: bookworm, buster +- CI pipelines defined in `.drone.jsonnet` + +## Key directories + +- `backend/` — Go backend services (API server, backend server, CLI, snap hooks) + - `cmd/` — Executables: api, backend, cli, install, post-refresh + - `rest/` — REST API endpoints (gorilla/mux) + - `auth/` — Authentication: OIDC, LDAP, Authelia integration + - `config/` — Configuration management (SQLite) + - `storage/` — Disk/btrfs management + - `installer/` — App installation service + - `backup/` — Backup/restore + - Built with `CGO_ENABLED` and static linking +- `www/` — Vue 3 frontend (Element Plus, Vite, TypeScript) +- `config/` — Configuration templates (authelia, ldap, nginx, errors) +- `authelia/` — Authelia auth server packaging +- `nginx/` — Nginx build/test scripts +- `bin/` — Platform utility scripts +- `meta/snap.yaml` — Snap metadata (services: nginx-public, openldap, backend, api, authelia, cli) +- `test/` — Python integration tests (pytest), Selenium UI tests, Go API tests +- `package.sh` — Creates snap package + +## Build pipeline steps (per arch) + +1. `version` — writes build number +2. `nginx` / `nginx test {distro}` — build and test nginx (tested on both bookworm and buster) +3. `authelia` / `authelia test` — package and test Authelia auth server +4. `build web` — npm install, test, lint, build Vue frontend +5. `build` — compile Go backend binaries (with `go test ./...` coverage) +6. `build api test` — compile Go API integration test binary +7. `package` — create `.snap` file + test app +8. `test {distro}` — integration tests per distro against bootstrap service containers +9. (amd64 only) `selenium` + `test-ui-desktop` + `test-ui-mobile` — Selenium UI tests +10. `test-upgrade` — upgrade path testing +11. `upload` / `promote` — publish to release repo (stable/master branches only) +12. `artifact` — upload test artifacts via SCP + +# Running Drone builds locally + +Generate `.drone.yml` from jsonnet (run from project root): +``` +drone jsonnet --stdout --stream > .drone.yml +``` + +Run a specific pipeline with selected steps: +``` +drone exec --pipeline amd64 --trusted \ + --include version \ + --include nginx \ + --include authelia \ + --include "build web" \ + --include build \ + --include package \ + --include "test bookworm" \ + .drone.yml +``` diff --git a/authelia/authelia.sh b/authelia/authelia.sh index aeccb94dd..11f40c929 100755 --- a/authelia/authelia.sh +++ b/authelia/authelia.sh @@ -1,3 +1,5 @@ #!/bin/bash -e DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) -${DIR}/lib/ld-musl-*.so* --library-path ${DIR}/lib ${DIR}/authelia "$@" +LD=$(find ${DIR}/lib -name 'ld-*.so*' -type f -print -quit) +LIBS=$(echo ${DIR}/lib/*-linux-gnu*) +exec ${LD} --library-path $LIBS ${DIR}/authelia "$@" diff --git a/authelia/package.sh b/authelia/build.sh similarity index 79% rename from authelia/package.sh rename to authelia/build.sh index 68a29ffeb..431fc0952 100755 --- a/authelia/package.sh +++ b/authelia/build.sh @@ -5,5 +5,6 @@ cd ${DIR} BUILD_DIR=${DIR}/../build/snap/authelia mkdir -p ${BUILD_DIR} cp /app/authelia ${BUILD_DIR} -cp -r /lib ${BUILD_DIR} +cp -rL /lib ${BUILD_DIR} cp ${DIR}/authelia.sh ${BUILD_DIR} +${BUILD_DIR}/authelia -v diff --git a/backend/auth/oidc.go b/backend/auth/oidc.go new file mode 100644 index 000000000..0f5d12c35 --- /dev/null +++ b/backend/auth/oidc.go @@ -0,0 +1,161 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "go.uber.org/zap" + "io" + "net" + "net/http" + "net/url" + "strings" +) + +type OIDCConfig interface { + DeviceUrl() string + Url(app string) string +} + +type OIDCService struct { + config OIDCConfig + socketPath string + logger *zap.Logger +} + +func NewOIDCService(config OIDCConfig, socketPath string, logger *zap.Logger) *OIDCService { + return &OIDCService{ + config: config, + socketPath: socketPath, + logger: logger, + } +} + +func (s *OIDCService) GetAuthorizationURL() (authURL string, state string, codeVerifier string, err error) { + state, err = randomString(32) + if err != nil { + return "", "", "", fmt.Errorf("generate state: %w", err) + } + + codeVerifier, err = randomString(64) + if err != nil { + return "", "", "", fmt.Errorf("generate code verifier: %w", err) + } + + codeChallenge := generateCodeChallenge(codeVerifier) + redirectURI := s.config.DeviceUrl() + "/rest/oidc/callback" + authEndpoint := s.config.Url("auth") + "/api/oidc/authorization" + + params := url.Values{ + "client_id": {"syncloud"}, + "response_type": {"code"}, + "redirect_uri": {redirectURI}, + "scope": {"openid profile email groups"}, + "state": {state}, + "code_challenge": {codeChallenge}, + "code_challenge_method": {"S256"}, + } + + authURL = authEndpoint + "?" + params.Encode() + return authURL, state, codeVerifier, nil +} + +func (s *OIDCService) ExchangeCode(code string, codeVerifier string) (string, error) { + tokenEndpoint := "http://authelia/api/oidc/token" + redirectURI := s.config.DeviceUrl() + "/rest/oidc/callback" + + data := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {"syncloud"}, + "code": {code}, + "redirect_uri": {redirectURI}, + "code_verifier": {codeVerifier}, + } + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", s.socketPath) + }, + }, + } + resp, err := client.PostForm(tokenEndpoint, data) + if err != nil { + return "", fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body)) + } + + var tokenResponse struct { + IDToken string `json:"id_token"` + } + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return "", fmt.Errorf("parse token response: %w", err) + } + + if tokenResponse.IDToken == "" { + return "", fmt.Errorf("no id_token in response") + } + + username, err := extractUsernameFromIDToken(tokenResponse.IDToken) + if err != nil { + return "", err + } + + return username, nil +} + +func extractUsernameFromIDToken(idToken string) (string, error) { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return "", fmt.Errorf("invalid id_token format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("decode id_token payload: %w", err) + } + + var claims struct { + PreferredUsername string `json:"preferred_username"` + Subject string `json:"sub"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", fmt.Errorf("parse id_token claims: %w", err) + } + + username := claims.PreferredUsername + if username == "" { + username = claims.Subject + } + if username == "" { + return "", fmt.Errorf("no username in id_token") + } + + return username, nil +} + +func randomString(length int) (string, error) { + bytes := make([]byte, length) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes)[:length], nil +} + +func generateCodeChallenge(codeVerifier string) string { + hash := sha256.Sum256([]byte(codeVerifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} diff --git a/backend/auth/web.go b/backend/auth/web.go index 675a2372f..59e3c4474 100644 --- a/backend/auth/web.go +++ b/backend/auth/web.go @@ -5,12 +5,16 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "fmt" "github.com/google/uuid" + "github.com/syncloud/platform/cli" "github.com/syncloud/platform/config" "github.com/syncloud/platform/parser" "go.uber.org/zap" + "io" "os" "path" + "path/filepath" "sync" ) @@ -19,15 +23,16 @@ type Web interface { } type Variables struct { - Domain string - AppUrl string - EncryptionKey string - JwtSecret string - HmacSecret string - DeviceUrl string - AuthUrl string - IsActivated bool - OIDCClients []config.OIDCClient + Domain string + AppUrl string + EncryptionKey string + JwtSecret string + HmacSecret string + DeviceUrl string + AuthUrl string + IsActivated bool + TwoFactorEnabled bool + OIDCClients []config.OIDCClient } type Authelia struct { @@ -41,6 +46,7 @@ type Authelia struct { userConfig UserConfig systemd Systemd generator PasswordGenerator + executor cli.Executor logger *zap.Logger } @@ -51,6 +57,7 @@ type UserConfig interface { OIDCClients() ([]config.OIDCClient, error) AddOIDCClient(client config.OIDCClient) error IsActivated() bool + IsTwoFactorEnabled() bool } type Systemd interface { @@ -75,6 +82,7 @@ func NewWeb( userConfig UserConfig, systemd Systemd, generator PasswordGenerator, + executor cli.Executor, logger *zap.Logger, ) *Authelia { return &Authelia{ @@ -88,6 +96,7 @@ func NewWeb( userConfig: userConfig, systemd: systemd, generator: generator, + executor: executor, logger: logger, } } @@ -146,25 +155,61 @@ func (w *Authelia) InitConfig() error { return err } variables := Variables{ - Domain: w.userConfig.GetDeviceDomain(), - EncryptionKey: encryptionKey, - JwtSecret: jwtSecret, - HmacSecret: hmacSecret, - DeviceUrl: w.userConfig.DeviceUrl(), - AuthUrl: w.userConfig.Url("auth"), - IsActivated: activated, - OIDCClients: clients, + Domain: w.userConfig.GetDeviceDomain(), + EncryptionKey: encryptionKey, + JwtSecret: jwtSecret, + HmacSecret: hmacSecret, + DeviceUrl: w.userConfig.DeviceUrl(), + AuthUrl: w.userConfig.Url("auth"), + IsActivated: activated, + TwoFactorEnabled: w.userConfig.IsTwoFactorEnabled(), + OIDCClients: clients, } + tmpDir := w.outDir + ".tmp" + err = os.RemoveAll(tmpDir) + if err != nil { + return err + } + err = os.MkdirAll(tmpDir, 0755) + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + err = copyAssetsDir( + path.Join(w.inputDir, "assets"), + path.Join(tmpDir, "assets"), + ) + if err != nil { + w.logger.Warn("unable to copy authelia assets", zap.Error(err)) + } + _ = os.MkdirAll(path.Join(w.outDir, "assets"), 0755) + err = parser.Generate( w.inputDir, - w.outDir, + tmpDir, variables, ) if err != nil { return err } + output, err := w.executor.CombinedOutput( + "snap", "run", "platform.authelia-cli", + "validate-config", + "--config", path.Join(tmpDir, "config.yml"), + "--config.experimental.filters", "template", + ) + if err != nil { + return fmt.Errorf("authelia config validation failed, contact support: %s", string(output)) + } + + err = copyDir(tmpDir, w.outDir) + if err != nil { + return err + } + err = w.systemd.RestartService("platform.authelia") if err != nil { w.logger.Error("unable to restart authelia", zap.Error(err)) @@ -174,6 +219,44 @@ func (w *Authelia) InitConfig() error { return nil } +func copyDir(srcDir string, dstDir string) error { + err := os.MkdirAll(dstDir, 0755) + if err != nil { + return err + } + entries, err := os.ReadDir(srcDir) + if err != nil { + return err + } + for _, entry := range entries { + src := filepath.Join(srcDir, entry.Name()) + dst := filepath.Join(dstDir, entry.Name()) + if entry.IsDir() { + err = copyDir(src, dst) + if err != nil { + return err + } + continue + } + srcFile, err := os.Open(src) + if err != nil { + return err + } + dstFile, err := os.Create(dst) + if err != nil { + srcFile.Close() + return err + } + _, err = io.Copy(dstFile, srcFile) + srcFile.Close() + dstFile.Close() + if err != nil { + return err + } + } + return nil +} + func getOrCreateUuid(file string) (string, error) { _, err := os.Stat(file) if os.IsNotExist(err) { @@ -188,6 +271,44 @@ func getOrCreateUuid(file string) (string, error) { return string(content), nil } +func copyAssetsDir(srcDir string, dstDir string) error { + _, err := os.Stat(srcDir) + if os.IsNotExist(err) { + return nil + } + err = os.MkdirAll(dstDir, 0755) + if err != nil { + return err + } + entries, err := os.ReadDir(srcDir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + src := filepath.Join(srcDir, entry.Name()) + dst := filepath.Join(dstDir, entry.Name()) + srcFile, err := os.Open(src) + if err != nil { + return err + } + dstFile, err := os.Create(dst) + if err != nil { + srcFile.Close() + return err + } + _, err = io.Copy(dstFile, srcFile) + srcFile.Close() + dstFile.Close() + if err != nil { + return err + } + } + return nil +} + func createRsaKeyFileIfMissing(file string) error { _, err := os.Stat(file) if err == nil || !os.IsNotExist(err) { diff --git a/backend/auth/web_test.go b/backend/auth/web_test.go index a01ccc643..1969a4973 100644 --- a/backend/auth/web_test.go +++ b/backend/auth/web_test.go @@ -21,6 +21,10 @@ func (u *UserConfigStub) AddOIDCClient(client config.OIDCClient) error { return nil } +func (u *UserConfigStub) IsTwoFactorEnabled() bool { + return false +} + func (u *UserConfigStub) IsActivated() bool { return u.activated } @@ -55,11 +59,12 @@ func (p *PasswordGeneratorStub) Generate() (Secret, error) { return Secret{Password: "pass", Hash: "hash"}, nil } + func TestWebInit(t *testing.T) { userConfig := &UserConfigStub{domain: "www.localhost", activated: false} outDir := t.TempDir() secretDir := t.TempDir() - web := NewWeb("../../config/authelia", outDir, secretDir, userConfig, &SystemdStub{}, &PasswordGeneratorStub{}, log.Default()) + web := NewWeb("../../config/authelia", outDir, secretDir, userConfig, &SystemdStub{}, &PasswordGeneratorStub{}, &ExecutorStub{}, log.Default()) err := web.InitConfig() assert.NoError(t, err) @@ -84,7 +89,7 @@ func TestWebReInit(t *testing.T) { err = os.WriteFile(secretFilePath, []byte("secret"), 0644) assert.Nil(t, err) - web := NewWeb("../../config/authelia", outDir, secretDir, userConfig, &SystemdStub{}, &PasswordGeneratorStub{}, log.Default()) + web := NewWeb("../../config/authelia", outDir, secretDir, userConfig, &SystemdStub{}, &PasswordGeneratorStub{}, &ExecutorStub{}, log.Default()) err = web.InitConfig() assert.Nil(t, err) @@ -126,7 +131,7 @@ func TestWebClients(t *testing.T) { }, activated: false} outDir := t.TempDir() secretDir := t.TempDir() - web := NewWeb("../../config/authelia", outDir, secretDir, userConfig, &SystemdStub{}, &PasswordGeneratorStub{}, log.Default()) + web := NewWeb("../../config/authelia", outDir, secretDir, userConfig, &SystemdStub{}, &PasswordGeneratorStub{}, &ExecutorStub{}, log.Default()) err := web.InitConfig() assert.NoError(t, err) diff --git a/backend/cmd/cli/disable2fa.go b/backend/cmd/cli/disable2fa.go new file mode 100644 index 000000000..752aae1e8 --- /dev/null +++ b/backend/cmd/cli/disable2fa.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "github.com/spf13/cobra" + "github.com/syncloud/platform/auth" + "github.com/syncloud/platform/config" +) + +func disable2faCmd(userConfig *string, systemConfig *string) *cobra.Command { + return &cobra.Command{ + Use: "disable-2fa", + Short: "Disable two-factor authentication", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := Init(*userConfig, *systemConfig) + if err != nil { + return err + } + return c.Call(func(configuration *config.UserConfig, authelia *auth.Authelia) { + configuration.SetTwoFactorEnabled(false) + fmt.Println("2FA disabled in config") + err := authelia.InitConfig() + if err != nil { + fmt.Printf("warning: unable to regenerate authelia config: %v\n", err) + } else { + fmt.Println("Authelia config regenerated and service restarted") + } + }) + }, + } +} diff --git a/backend/cmd/cli/login.go b/backend/cmd/cli/login.go new file mode 100644 index 000000000..2bd0c198a --- /dev/null +++ b/backend/cmd/cli/login.go @@ -0,0 +1,62 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/syncloud/platform/auth" +) + +const TokenFile = "/var/snap/platform/common/login-token" + +func loginCmd(userConfig *string, systemConfig *string) *cobra.Command { + return &cobra.Command{ + Use: "login [username] [password]", + Short: "Generate a one-time login token", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + username := args[0] + password := args[1] + + c, err := Init(*userConfig, *systemConfig) + if err != nil { + return err + } + var authErr error + err = c.Call(func(authService *auth.Service) { + ok, err := authService.Authenticate(username, password) + if err != nil { + authErr = fmt.Errorf("authentication failed: %w", err) + return + } + if !ok { + authErr = fmt.Errorf("authentication failed") + return + } + + tokenBytes := make([]byte, 32) + _, err = rand.Read(tokenBytes) + if err != nil { + authErr = fmt.Errorf("unable to generate token: %w", err) + return + } + token := hex.EncodeToString(tokenBytes) + + err = os.WriteFile(TokenFile, []byte(token+":"+username), 0600) + if err != nil { + authErr = fmt.Errorf("unable to write token file: %w", err) + return + } + + fmt.Print(token) + }) + if err != nil { + return err + } + return authErr + }, + } +} diff --git a/backend/cmd/cli/main.go b/backend/cmd/cli/main.go index f5e140fab..f4cacb967 100644 --- a/backend/cmd/cli/main.go +++ b/backend/cmd/cli/main.go @@ -20,6 +20,8 @@ func main() { certCmd(userConfig, systemConfig), btrfsCmd(userConfig, systemConfig), backupCmd(userConfig, systemConfig), + disable2faCmd(userConfig, systemConfig), + loginCmd(userConfig, systemConfig), ) err := rootCmd.Execute() diff --git a/backend/config/user_config.go b/backend/config/user_config.go index 35c05cf5d..e0ca36996 100644 --- a/backend/config/user_config.go +++ b/backend/config/user_config.go @@ -583,6 +583,15 @@ func (c *UserConfig) AppDomain(app string) string { return fmt.Sprintf("%s.%s", app, c.GetDeviceDomain()) } +func (c *UserConfig) IsTwoFactorEnabled() bool { + result := c.Get("platform.two_factor_enabled", DbFalse) + return c.toBool(result) +} + +func (c *UserConfig) SetTwoFactorEnabled(enabled bool) { + c.Upsert("platform.two_factor_enabled", c.fromBool(enabled)) +} + func (c *UserConfig) Url(app string) string { port := c.GetPublicPort() domain := c.GetDeviceDomain() diff --git a/backend/ioc/common.go b/backend/ioc/common.go index 1239e1af4..9ba2a344a 100644 --- a/backend/ioc/common.go +++ b/backend/ioc/common.go @@ -316,10 +316,18 @@ func Init(userConfig string, systemConfig string, backupDir string, varDir strin return nil, err } + err = c.Singleton(func(userConfig *config.UserConfig, systemConfig *config.SystemConfig) *auth.OIDCService { + return auth.NewOIDCService(userConfig, path.Join(systemConfig.DataDir(), "authelia.socket"), logger) + }) + if err != nil { + return nil, err + } + err = c.Singleton(func( userConfig *config.UserConfig, systemd *systemd.Control, secretGenerator *auth.SecretGenerator, + executor *cli.ShellExecutor, ) *auth.Authelia { return auth.NewWeb( path.Join(hook.AppDir, "config/authelia"), @@ -328,6 +336,7 @@ func Init(userConfig string, systemConfig string, backupDir string, varDir strin userConfig, systemd, secretGenerator, + executor, logger, ) }) diff --git a/backend/ioc/public_api.go b/backend/ioc/public_api.go index a0a5215d0..27c63c012 100644 --- a/backend/ioc/public_api.go +++ b/backend/ioc/public_api.go @@ -36,11 +36,13 @@ func InitPublicApi(userConfig string, systemConfig string, backupDir string, var executor *cli.ShellExecutor, iface *network.TcpInterfaces, sender *support.Sender, proxy *rest.Proxy, middleware *rest.Middleware, ldapService *auth.Service, cookies *session.Cookies, changesClient *snap.ChangesClient, + oidcService *auth.OIDCService, authelia *auth.Authelia, ) *rest.Backend { return rest.NewBackend(master, backupService, eventTrigger, worker, redirectService, installerService, storageService, id, activate, userConfig, cert, externalAddress, snapd, disks, journalCtl, executor, iface, sender, proxy, - ldapService, middleware, cookies, net, address, changesClient, logger) + ldapService, middleware, cookies, net, address, changesClient, + oidcService, authelia, logger) }) if err != nil { return nil, err diff --git a/backend/parser/template.go b/backend/parser/template.go index 43551dc0b..78eaf536d 100644 --- a/backend/parser/template.go +++ b/backend/parser/template.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "text/template" ) @@ -16,7 +17,25 @@ func Generate(input, output string, data interface{}) error { } } - var templates = template.Must(template.ParseGlob(fmt.Sprintf("%s/*", input))) + entries, err := filepath.Glob(fmt.Sprintf("%s/*", input)) + if err != nil { + return err + } + var files []string + for _, entry := range entries { + info, err := os.Stat(entry) + if err != nil { + return err + } + if !info.IsDir() { + files = append(files, entry) + } + } + if len(files) == 0 { + return fmt.Errorf("no template files found in %s", input) + } + + var templates = template.Must(template.ParseFiles(files...)) for _, t := range templates.Templates() { err := write(output, t, data) if err != nil { diff --git a/backend/rest/backend.go b/backend/rest/backend.go index b7fb5af82..400f8170d 100644 --- a/backend/rest/backend.go +++ b/backend/rest/backend.go @@ -4,6 +4,9 @@ import ( "encoding/json" "errors" "fmt" + "os" + "strings" + "github.com/gorilla/mux" "github.com/syncloud/platform/access" "github.com/syncloud/platform/auth" @@ -51,6 +54,8 @@ type Backend struct { auth *auth.Service mw *Middleware cookies *session.Cookies + oidc *auth.OIDCService + authelia *auth.Authelia network string address string logger *zap.Logger @@ -65,6 +70,7 @@ func NewBackend( iface *network.TcpInterfaces, support *support.Sender, proxy *Proxy, auth *auth.Service, middleware *Middleware, cookies *session.Cookies, network string, address string, changesClient *snap.ChangesClient, + oidcService *auth.OIDCService, authelia *auth.Authelia, logger *zap.Logger) *Backend { return &Backend{ @@ -90,6 +96,8 @@ func NewBackend( auth: auth, mw: middleware, cookies: cookies, + oidc: oidcService, + authelia: authelia, network: network, address: address, changesClient: changesClient, @@ -115,7 +123,9 @@ func (b *Backend) Start() error { r.HandleFunc("/rest/id", b.mw.Handle(b.Id)).Methods("GET") r.HandleFunc("/rest/activation/status", b.mw.Handle(b.IsActivated)).Methods("GET") - r.HandleFunc("/rest/login", b.mw.FailIfNotActivated(b.UserLogin)).Methods("POST") + r.HandleFunc("/rest/oidc/login", b.mw.FailIfNotActivated(b.OIDCLogin)).Methods("GET") + r.HandleFunc("/rest/oidc/callback", b.mw.FailIfNotActivated(b.OIDCCallback)).Methods("GET") + r.HandleFunc("/rest/login/token", b.mw.FailIfNotActivated(b.LoginToken)).Methods("POST") r.HandleFunc("/rest/redirect_info", b.mw.FailIfActivated(b.mw.Handle(b.RedirectInfo))).Methods("GET") r.PathPrefix("/rest/redirect/domain/availability").Handler(http.StripPrefix("/rest/redirect", NewFailIfActivatedHandler(b.userConfig, proxyRedirect))) @@ -124,6 +134,8 @@ func (b *Backend) Start() error { r.HandleFunc("/rest/user", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.User))).Methods("GET") r.HandleFunc("/rest/logout", b.mw.FailIfNotActivated(b.mw.Secured(b.UserLogout))).Methods("POST") + r.HandleFunc("/rest/settings/2fa", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.GetTwoFactorSettings))).Methods("GET") + r.HandleFunc("/rest/settings/2fa", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.SetTwoFactorSettings))).Methods("POST") r.HandleFunc("/rest/job/status", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.JobStatus))).Methods("GET") r.HandleFunc("/rest/backup/list", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.BackupList))).Methods("GET") r.HandleFunc("/rest/backup/auto", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.GetBackupAuto))).Methods("GET") @@ -253,33 +265,113 @@ func (b *Backend) RedirectInfo(_ *http.Request) (interface{}, error) { return response, nil } -func (b *Backend) UserLogin(w http.ResponseWriter, req *http.Request) { - var request model.UserLoginRequest - err := json.NewDecoder(req.Body).Decode(&request) +func (b *Backend) OIDCLogin(w http.ResponseWriter, req *http.Request) { + authURL, state, codeVerifier, err := b.oidc.GetAuthorizationURL() if err != nil { b.mw.Fail(w, model.BadRequest(err)) return } - authenticated, err := b.auth.Authenticate(request.Username, request.Password) + err = b.cookies.SetOIDCState(w, req, state, codeVerifier) if err != nil { b.mw.Fail(w, model.BadRequest(err)) return } - if !authenticated { - b.mw.Fail(w, model.BadRequest(fmt.Errorf("invalid credentials"))) + http.Redirect(w, req, authURL, http.StatusFound) +} + +func (b *Backend) OIDCCallback(w http.ResponseWriter, req *http.Request) { + query := req.URL.Query() + code := query.Get("code") + state := query.Get("state") + + if code == "" || state == "" { + b.mw.Fail(w, model.BadRequest(fmt.Errorf("missing code or state"))) + return + } + + savedState, codeVerifier, err := b.cookies.GetOIDCState(req) + if err != nil { + b.mw.Fail(w, model.BadRequest(fmt.Errorf("no OIDC session"))) + return + } + + if state != savedState { + b.mw.Fail(w, model.BadRequest(fmt.Errorf("invalid state"))) + return + } + + username, err := b.oidc.ExchangeCode(code, codeVerifier) + if err != nil { + b.mw.Fail(w, model.BadRequest(err)) return } + + err = b.cookies.ClearOIDCState(w, req) + if err != nil { + b.logger.Warn("unable to clear OIDC state", zap.Error(err)) + } + err = b.cookies.ClearSessionUser(w, req) if err != nil { b.mw.Fail(w, model.BadRequest(err)) return } - err = b.cookies.SetSessionUser(w, req, request.Username) + err = b.cookies.SetSessionUser(w, req, username) if err != nil { b.mw.Fail(w, model.BadRequest(err)) return } - http.Redirect(w, req, "/", http.StatusMovedPermanently) + http.Redirect(w, req, "/", http.StatusFound) +} + +const loginTokenFile = "/var/snap/platform/common/login-token" + +func (b *Backend) LoginToken(w http.ResponseWriter, req *http.Request) { + var request struct { + Token string `json:"token"` + } + err := json.NewDecoder(req.Body).Decode(&request) + if err != nil || request.Token == "" { + b.mw.Fail(w, model.BadRequest(fmt.Errorf("missing token"))) + return + } + + data, err := os.ReadFile(loginTokenFile) + if err != nil { + b.mw.Fail(w, model.BadRequest(fmt.Errorf("no pending login token"))) + return + } + + parts := strings.SplitN(string(data), ":", 2) + if len(parts) != 2 { + _ = os.Remove(loginTokenFile) + b.mw.Fail(w, model.BadRequest(fmt.Errorf("invalid token file"))) + return + } + + savedToken := parts[0] + username := parts[1] + + if request.Token != savedToken { + b.mw.Fail(w, model.BadRequest(fmt.Errorf("invalid token"))) + return + } + + _ = os.Remove(loginTokenFile) + + err = b.cookies.SetSessionUser(w, req, username) + if err != nil { + b.mw.Fail(w, model.BadRequest(err)) + return + } + + response := model.Response{Success: true} + responseJson, err := json.Marshal(response) + if err != nil { + b.mw.Fail(w, model.BadRequest(err)) + return + } + _, _ = fmt.Fprint(w, string(responseJson)) } func (b *Backend) GetAccess(_ *http.Request) (interface{}, error) { @@ -478,5 +570,28 @@ func (b *Backend) UserLogout(w http.ResponseWriter, req *http.Request) { b.mw.Fail(w, &model.ServiceError{InternalError: err, StatusCode: http.StatusBadRequest}) return } - http.Redirect(w, req, "/", http.StatusMovedPermanently) + http.Redirect(w, req, "/", http.StatusFound) +} + +func (b *Backend) GetTwoFactorSettings(_ *http.Request) (interface{}, error) { + return map[string]interface{}{ + "enabled": b.userConfig.IsTwoFactorEnabled(), + "authelia_url": b.userConfig.Url("auth"), + }, nil +} + +func (b *Backend) SetTwoFactorSettings(req *http.Request) (interface{}, error) { + var request struct { + Enabled bool `json:"enabled"` + } + err := json.NewDecoder(req.Body).Decode(&request) + if err != nil { + return nil, errors.New("bad request") + } + b.userConfig.SetTwoFactorEnabled(request.Enabled) + err = b.authelia.InitConfig() + if err != nil { + return nil, fmt.Errorf("unable to apply 2FA settings: %w", err) + } + return "OK", nil } diff --git a/backend/session/cookies.go b/backend/session/cookies.go index 4f786675c..b80f872af 100644 --- a/backend/session/cookies.go +++ b/backend/session/cookies.go @@ -8,6 +8,8 @@ import ( ) const UserKey = "user" +const OIDCStateKey = "oidc_state" +const OIDCCodeVerifierKey = "oidc_code_verifier" type Cookies struct { config Config @@ -71,3 +73,40 @@ func (c *Cookies) GetSessionUser(r *http.Request) (string, error) { return user.(string), nil } + +func (c *Cookies) SetOIDCState(w http.ResponseWriter, r *http.Request, state string, codeVerifier string) error { + session, err := c.getSession(r) + if err != nil { + c.logger.Error("cannot update session for OIDC state", zap.Error(err)) + return err + } + session.Values[OIDCStateKey] = state + session.Values[OIDCCodeVerifierKey] = codeVerifier + return session.Save(r, w) +} + +func (c *Cookies) GetOIDCState(r *http.Request) (string, string, error) { + session, err := c.getSession(r) + if err != nil { + return "", "", err + } + state, found := session.Values[OIDCStateKey] + if !found { + return "", "", fmt.Errorf("no OIDC state found") + } + codeVerifier, found := session.Values[OIDCCodeVerifierKey] + if !found { + return "", "", fmt.Errorf("no OIDC code verifier found") + } + return state.(string), codeVerifier.(string), nil +} + +func (c *Cookies) ClearOIDCState(w http.ResponseWriter, r *http.Request) error { + session, err := c.getSession(r) + if err != nil { + return err + } + delete(session.Values, OIDCStateKey) + delete(session.Values, OIDCCodeVerifierKey) + return session.Save(r, w) +} diff --git a/config/authelia/assets/logo.svg b/config/authelia/assets/logo.svg new file mode 100644 index 000000000..40b88125e --- /dev/null +++ b/config/authelia/assets/logo.svg @@ -0,0 +1,27 @@ + + + + + logo_white + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/authelia/config.yml b/config/authelia/config.yml index 45c10a711..247db4fa4 100644 --- a/config/authelia/config.yml +++ b/config/authelia/config.yml @@ -52,7 +52,7 @@ server: ## Set the path on disk to Authelia assets. ## Useful to allow overriding of specific static assets. - # asset_path: /config/assets/ + asset_path: /var/snap/platform/current/config/authelia/assets/ endpoints: ## Enables the pprof endpoint. @@ -493,7 +493,7 @@ password_policy: access_control: ## Default policy can either be 'bypass', 'one_factor', 'two_factor' or 'deny'. It is the policy applied to any ## resource if there is no policy to be applied to the user. - default_policy: one_factor + default_policy: {{ if .TwoFactorEnabled }}two_factor{{ else }}one_factor{{ end }} # networks: # - name: internal @@ -746,35 +746,8 @@ notifier: ## You can disable the notifier startup check by setting this to true. disable_startup_check: true - ## - ## File System (Notification Provider) - ## - ## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness - ## - # filesystem: - # filename: /config/notification.txt - - ## - ## SMTP (Notification Provider) - ## - ## Use a SMTP server for sending notifications. Authelia uses the PLAIN or LOGIN methods to authenticate. - ## [Security] By default Authelia will: - ## - force all SMTP connections over TLS including unauthenticated connections - ## - use the disable_require_tls boolean value to disable this requirement - ## (only works for unauthenticated connections) - ## - validate the SMTP server x509 certificate during the TLS handshake against the hosts trusted certificates - ## (configure in tls section) - smtp: - address: smtp://127.0.0.1:25 - # timeout: 5s - # username: test - # password: password - sender: "Syncloud " - # identifier: localhost - subject: "[Syncloud] {title}" - # startup_check_address: test@authelia.com - # disable_require_tls: false - # disable_html_emails: false + filesystem: + filename: /var/snap/platform/current/authelia-notification.txt identity_providers: oidc: @@ -818,9 +791,11 @@ identity_providers: require_pkce: true pkce_challenge_method: 'S256' redirect_uris: - - 'http://localhost/callback' + - '{{ .DeviceUrl }}/rest/oidc/callback' scopes: - - 'offline_access' + - 'openid' + - 'email' + - 'profile' - 'groups' requested_audience_mode: 'implicit' grant_types: @@ -829,17 +804,17 @@ identity_providers: response_types: - 'code' response_modes: - - 'form_post' - consent_mode: 'explicit' - require_pushed_authorization_requests: true + - 'query' + consent_mode: 'implicit' + require_pushed_authorization_requests: false token_endpoint_auth_method: 'none' - authorization_policy: 'one_factor' + authorization_policy: '{{ if .TwoFactorEnabled }}two_factor{{ else }}one_factor{{ end }}' {{ range $key, $value := .OIDCClients }} - client_id: '{{ .ID }}' client_secret: '{{ .Secret }}' public: false - authorization_policy: 'one_factor' + authorization_policy: '{{ if $.TwoFactorEnabled }}two_factor{{ else }}one_factor{{ end }}' redirect_uris: - '{{ .RedirectURI }}' scopes: diff --git a/test/requirements.txt b/test/requirements.txt index 138e851d2..103ee1472 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,6 +1,7 @@ pytest==8.4.1 selenium==4.35.0 -syncloud-lib==356 +syncloud-lib==360 pytest-retry==1.6.3 retrying==1.4.2 responses==0.5.0 +pyotp==2.9.0 diff --git a/test/test.py b/test/test.py index 14e010551..3745f8173 100644 --- a/test/test.py +++ b/test/test.py @@ -80,7 +80,7 @@ def test_https_port_validation_url(device_host): def test_non_activated_device_login_redirect_to_activation(full_domain): def login(): - response = requests.post('https://{0}/rest/login'.format(full_domain), verify=False) + response = requests.get('https://{0}/rest/oidc/login'.format(full_domain), verify=False) if response.status_code != 501: raise Exception() retry(login) @@ -202,7 +202,7 @@ def check(): def test_deactivate(device, domain): wait_for_activation(domain) - response = device.login().post('https://{0}/rest/deactivate'.format(domain), verify=False, + response = device.login_v2().post('https://{0}/rest/deactivate'.format(domain), verify=False, allow_redirects=False) assert '"success":true' in response.text assert response.status_code == 200 @@ -275,7 +275,7 @@ def test_testapp_access_change_hook(device_host): def test_get_access(device, domain): - response = device.login().get('https://{0}/rest/access'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/access'.format(domain), verify=False) print(response.text) assert json.loads(response.text)["success"] assert json.loads(response.text)["data"]["ipv4_enabled"] @@ -283,32 +283,32 @@ def test_get_access(device, domain): def test_installer_status(device, device_host): - response = device.login().get('https://{0}/rest/installer/status'.format(device_host), allow_redirects=False, verify=False) + response = device.login_v2().get('https://{0}/rest/installer/status'.format(device_host), allow_redirects=False, verify=False) assert response.status_code == 200 assert not json.loads(response.text)["data"]["is_running"], response.text def test_network_interfaces(device, domain): - response = device.login().get('https://{0}/rest/network/interfaces'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/network/interfaces'.format(domain), verify=False) print(response.text) assert json.loads(response.text)["success"] assert response.status_code == 200 def test_send_logs(device, domain): - response = device.login().post('https://{0}/rest/logs/send?include_support=false'.format(domain), verify=False) + response = device.login_v2().post('https://{0}/rest/logs/send?include_support=false'.format(domain), verify=False) print(response.text) assert json.loads(response.text)["success"] assert response.status_code == 200 def test_proxy_image(device, domain): - response = device.login().get('https://{0}/rest/proxy/image?channel=stable&app=files'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/proxy/image?channel=stable&app=files'.format(domain), verify=False) assert response.status_code == 200 def test_available_apps(device, domain, artifact_dir): - response = device.login().get('https://{0}/rest/apps/available'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/apps/available'.format(domain), verify=False) with open('{0}/rest.available_apps.json'.format(artifact_dir), 'w') as the_file: the_file.write(response.text) assert response.status_code == 200 @@ -316,7 +316,7 @@ def test_available_apps(device, domain, artifact_dir): def test_device_url(device, domain, artifact_dir, full_domain): - response = device.login().get('https://{0}/rest/device/url'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/device/url'.format(domain), verify=False) with open('{0}/rest.device.url.json'.format(artifact_dir), 'w') as the_file: the_file.write(response.text) assert json.loads(response.text)["success"] @@ -325,29 +325,29 @@ def test_device_url(device, domain, artifact_dir, full_domain): def test_api_url_443(device, domain): - response = device.login().get('https://{0}/rest/access'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/access'.format(domain), verify=False) assert response.status_code == 200 - response = device.login().post('https://{0}/rest/access'.format(domain), verify=False, + response = device.login_v2().post('https://{0}/rest/access'.format(domain), verify=False, json={'ipv4_enabled': False, 'ipv4_public': False, 'access_port': 443}) assert json.loads(response.text)["success"] assert response.status_code == 200 - response = device.login().get('https://{0}/rest/access'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/access'.format(domain), verify=False) assert response.status_code == 200 def test_api_url_10000(device, domain): - response = device.login().post('https://{0}/rest/access'.format(domain), verify=False, + response = device.login_v2().post('https://{0}/rest/access'.format(domain), verify=False, json={'ipv4_enabled': False, 'ipv4_public': False, 'access_port': 10000}) assert json.loads(response.text)["success"] assert response.status_code == 200 - response = device.login().get('https://{0}/rest/access'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/access'.format(domain), verify=False) assert response.status_code == 200 @@ -356,7 +356,7 @@ def test_cron(device): def test_rest_installed_apps(device, domain, artifact_dir): - response = device.login().get('https://{0}/rest/apps/installed'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/apps/installed'.format(domain), verify=False) assert response.status_code == 200 with open('{0}/rest.installed_apps.json'.format(artifact_dir), 'w') as the_file: the_file.write(response.text) @@ -365,7 +365,7 @@ def test_rest_installed_apps(device, domain, artifact_dir): def test_rest_installed_app(device, domain, artifact_dir): - response = device.login().get('https://{0}/rest/app?app_id=testapp'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/app?app_id=testapp'.format(domain), verify=False) assert response.status_code == 200 with open('{0}/rest.app.installed.json'.format(artifact_dir), 'w') as the_file: the_file.write(response.text) @@ -373,7 +373,7 @@ def test_rest_installed_app(device, domain, artifact_dir): def test_rest_not_installed_app(device, domain, artifact_dir): - response = device.login().get('https://{0}/rest/app?app_id=files'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/app?app_id=files'.format(domain), verify=False) assert response.status_code == 200 with open('{0}/rest.app.not.installed.json'.format(artifact_dir), 'w') as the_file: the_file.write(response.text) @@ -381,20 +381,20 @@ def test_rest_not_installed_app(device, domain, artifact_dir): def test_install_app(device, device_host): - session = device.login() + session = device.login_v2() session.post('https://{0}/rest/app/install'.format(device_host), json={'app_id': 'files'}, verify=False) wait_for_installer(session, device_host) def test_rest_installed_app(device, device_host, artifact_dir): - response = device.login().get('https://{0}/rest/app?app_id=files'.format(device_host), verify=False) + response = device.login_v2().get('https://{0}/rest/app?app_id=files'.format(device_host), verify=False) assert response.status_code == 200 with open('{0}/rest.app.files.installed.json'.format(artifact_dir), 'w') as the_file: the_file.write(response.text) assert response.status_code == 200 def test_rest_platform_version(device, domain, artifact_dir): - response = device.login().get('https://{0}/rest/app?app_id=platform'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/app?app_id=platform'.format(domain), verify=False) assert response.status_code == 200 with open('{0}/rest.platform.version.json'.format(artifact_dir), 'w') as the_file: the_file.write(response.text) @@ -402,7 +402,7 @@ def test_rest_platform_version(device, domain, artifact_dir): def test_installer_upgrade(device, domain): - session = device.login() + session = device.login_v2() response = session.post('https://{0}/rest/installer/upgrade'.format(domain), verify=False) assert response.status_code == 200, response.text wait_for_jobs(domain, session) @@ -419,7 +419,7 @@ def wait_for_jobs(domain, session): def test_backup_rest(device, artifact_dir, domain): - session = device.login() + session = device.login_v2() response = session.post('https://{0}/rest/backup/create'.format(domain), json={'app': 'testapp'}, verify=False) assert response.status_code == 200 assert json.loads(response.text)['success'] @@ -453,7 +453,7 @@ def test_backup_cli(device, artifact_dir): def test_rest_backup_list(device, domain, artifact_dir): - response = device.login().get('https://{0}/rest/backup/list'.format(domain), verify=False) + response = device.login_v2().get('https://{0}/rest/backup/list'.format(domain), verify=False) assert response.status_code == 200 with open('{0}/rest.backup.list.json'.format(artifact_dir), 'w') as the_file: the_file.write(response.text) @@ -512,7 +512,7 @@ def disk_create(loop, fs, device): def disk_activate(loop, device, domain, artifact_dir): - session = device.login() + session = device.login_v2() response = session.get('https://{0}/rest/storage/disks'.format(domain)) print(response.text) with open('{0}/rest.storage.disks.json'.format(artifact_dir), 'w') as the_file: @@ -530,7 +530,7 @@ def disk_activate(loop, device, domain, artifact_dir): def disk_deactivate(loop, device, domain): - response = device.login().post('https://{0}/rest/storage/deactivate'.format(domain), verify=False, + response = device.login_v2().post('https://{0}/rest/storage/deactivate'.format(domain), verify=False, json={'device': loop}) assert response.status_code == 200 return current_disk_link(device) @@ -550,7 +550,7 @@ def cron_is_empty_after_install(device_host): def test_installer_version(domain, device, artifact_dir): - response = device.login().get('https://{0}/rest/installer/version'.format(domain), allow_redirects=False, + response = device.login_v2().get('https://{0}/rest/installer/version'.format(domain), allow_redirects=False, verify=False) with open('{0}/rest.installer.version.json'.format(artifact_dir), 'w') as the_file: the_file.write(response.text) @@ -596,7 +596,7 @@ def test_upgrade(app_archive_path, device_host, device, main_domain): def test_login_after_upgrade(device): - device.login() + device.login_v2() def test_if_cron_is_empty_after_upgrade(device_host): diff --git a/test/ui.py b/test/ui.py index 3fd99c998..8c7669fa0 100644 --- a/test/ui.py +++ b/test/ui.py @@ -3,8 +3,11 @@ from selenium.webdriver.support.ui import WebDriverWait import pytest +import re +import socket import time import requests +import pyotp from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from syncloudlib.integration.hosts import add_host_alias @@ -12,6 +15,7 @@ DIR = dirname(__file__) TMP_DIR = '/tmp/syncloud/ui' +stored_totp_secret = None @pytest.fixture(scope="session") @@ -36,13 +40,15 @@ def test_start(app, device_host, module_setup, domain, full_domain): add_host_alias("auth", device_host, full_domain) -def test_deactivate(device, main_domain, domain): +def test_deactivate(device, device_host, main_domain, domain, full_domain): device.activated() + ip = socket.gethostbyname(device_host) + device.run_ssh('echo "{0} auth.{1}" >> /etc/hosts'.format(ip, full_domain)) device.run_ssh('snap run platform.cli config set redirect.domain {}'.format(main_domain)) device.run_ssh('snap run platform.cli config set certbot.staging true') device.run_ssh('snap run platform.cli config set redirect.api_url http://api.redirect') - response = device.login().post('https://{0}/rest/deactivate'.format(domain), verify=False) + response = device.login_v2().post('https://{0}/rest/deactivate'.format(domain), verify=False) assert '"success":true' in response.text assert response.status_code == 200 @@ -80,26 +86,35 @@ def test_activate(selenium, device_host, selenium.screenshot('activate-ready') selenium.find_by_id('btn_activate').click() wait_for_loading(selenium.driver) - selenium.find_by_xpath("//h1[text()='Log in']") + wait_for_login(selenium, device_host) def test_activate_again(selenium, device_host): selenium.driver.get("https://{0}/activate".format(device_host)) - selenium.find_by_xpath("//h1[text()='Log in']") + wait_for_login(selenium, device_host) + + +def wait_for_login(selenium, device_host): + retries = 30 + for attempt in range(retries): + try: + selenium.find_by(By.ID, "username-textfield") + return + except Exception: + print('waiting for authelia (attempt {0}/{1})'.format(attempt + 1, retries)) + time.sleep(2) + selenium.driver.get("https://{0}".format(device_host)) + selenium.find_by(By.ID, "username-textfield") selenium.screenshot('activate') -def test_login(selenium, full_domain): +def test_login(selenium, full_domain, device_user, device_password): selenium.driver.get("https://{0}".format(full_domain)) - selenium.find_by_xpath("//h1[text()='Log in']") + # OIDC flow redirects to Authelia + selenium.find_by(By.ID, "username-textfield").send_keys(device_user) + selenium.find_by(By.ID, "password-textfield").send_keys(device_password) selenium.screenshot('login') - - -def test_index(selenium, device_user, device_password): - selenium.find_by_id("username").send_keys(device_user) - selenium.find_by_id("password").send_keys(device_password) - selenium.find_by_id("btn_login").click() - selenium.screenshot('index-progress') + selenium.find_by(By.ID, "sign-in-button").click() selenium.find_by_xpath("//h1[text()='Applications']") selenium.screenshot('index') @@ -217,15 +232,131 @@ def test_not_installed_app(selenium): def test_auth_web(selenium, full_domain, device_user, device_password): + selenium.driver.delete_all_cookies() selenium.driver.get("https://auth.{0}".format(full_domain)) selenium.find_by(By.ID, "username-textfield").send_keys(device_user) password = selenium.find_by(By.ID, "password-textfield") password.send_keys(device_password) selenium.screenshot('auth') selenium.find_by(By.ID, "sign-in-button").click() - + # redirect to the main web selenium.find_by_xpath("//h1[text()='Applications']") + + +def test_2fa_settings(selenium, full_domain): + selenium.driver.get("https://{0}".format(full_domain)) + settings(selenium, 'twofactor') + selenium.find_by_xpath("//h1[text()='Two-Factor Authentication']") + selenium.find_by_id('twofa_status') + selenium.screenshot('2fa_settings') + + +def test_2fa_enable(selenium, device, full_domain, device_user, device_password): + selenium.driver.get("https://{0}".format(full_domain)) + settings(selenium, 'twofactor') + selenium.find_by_xpath("//h1[text()='Two-Factor Authentication']") + + selenium.find_by_id('btn_enable_2fa').click() + selenium.find_by_id('btn_disable_2fa') + selenium.screenshot('2fa_enabled') + + auth_url = "https://auth.{0}".format(full_domain) + selenium.driver.get(auth_url + '/settings/one-time-password') + + if selenium.exists_by(By.ID, "username-textfield"): + selenium.find_by(By.ID, "username-textfield").send_keys(device_user) + selenium.find_by(By.ID, "password-textfield").send_keys(device_password) + selenium.find_by(By.ID, "sign-in-button").click() + selenium.screenshot('2fa_authelia_totp') + + selenium.find_by(By.ID, "register-link").click() + selenium.screenshot('2fa_settings_page') + + selenium.find_by(By.ID, "one-time-password-add").click() + + notification = device.run_ssh('cat /var/snap/platform/current/authelia-notification.txt') + otp_code = re.search(r'-{10,}\n\n(.+)\n\n-{10,}', notification).group(1) + selenium.screenshot('2fa_identity_verification') + selenium.find_by(By.ID, "one-time-code").send_keys(otp_code) + selenium.find_by(By.ID, "dialog-verify").click() + + selenium.screenshot('2fa_totp_register_config') + selenium.find_by(By.ID, "dialog-next").click() + + selenium.find_by(By.ID, "qr-toggle").click() + selenium.screenshot('2fa_totp_register') + + global stored_totp_secret + secret_element = selenium.find_by(By.ID, "secret-url") + secret_url = secret_element.get_attribute('value') + secret_match = re.search(r'secret=([A-Z2-7]+)', secret_url) + totp_secret = secret_match.group(1) + stored_totp_secret = totp_secret + + selenium.find_by(By.ID, "dialog-next").click() + totp = pyotp.TOTP(totp_secret) + code = totp.now() + otp_inputs = selenium.driver.find_elements(By.CSS_SELECTOR, "#otp-input input") + for i, digit in enumerate(code): + otp_inputs[i].send_keys(digit) + selenium.screenshot('2fa_totp_registered') + + +def test_2fa_login(selenium, device, full_domain, device_user, device_password): + selenium.driver.get("https://auth.{0}".format(full_domain)) + selenium.driver.delete_all_cookies() + selenium.driver.get("https://{0}".format(full_domain)) + selenium.driver.delete_all_cookies() + selenium.driver.get("https://{0}".format(full_domain)) + + selenium.find_by(By.ID, "username-textfield").send_keys(device_user) + selenium.find_by(By.ID, "password-textfield").send_keys(device_password) + selenium.find_by(By.ID, "sign-in-button").click() + + # TOTP challenge + selenium.find_by(By.ID, "otp-input") + selenium.screenshot('2fa_login_totp') + totp = pyotp.TOTP(stored_totp_secret) + # Wait for next TOTP period to avoid replay rejection + remaining = totp.interval - time.time() % totp.interval + time.sleep(remaining + 1) + code = totp.now() + otp_inputs = selenium.driver.find_elements(By.CSS_SELECTOR, "#otp-input input") + for i, digit in enumerate(code): + otp_inputs[i].send_keys(digit) + + selenium.find_by_xpath("//h1[text()='Applications']") + selenium.screenshot('2fa_login_success') + + +def test_2fa_disable(selenium, full_domain): + settings(selenium, 'twofactor') + selenium.find_by_xpath("//h1[text()='Two-Factor Authentication']") + selenium.find_by_id('btn_disable_2fa').click() + selenium.screenshot('2fa_disabled') + + +def test_2fa_recovery_cli(device, selenium, full_domain, device_user, device_password): + # enable 2FA again via API + session = device.login_v2() + session.post('https://{0}/rest/settings/2fa'.format(full_domain), + json={'enabled': True}, verify=False) + + # disable via CLI + device.run_ssh('snap run platform.cli disable-2fa') + time.sleep(2) + + # verify login works without TOTP + menu(selenium, 'logout') + selenium.driver.get("https://auth.{0}".format(full_domain)) + selenium.driver.delete_all_cookies() + selenium.driver.get("https://{0}".format(full_domain)) + selenium.find_by(By.ID, "username-textfield").send_keys(device_user) + selenium.find_by(By.ID, "password-textfield").send_keys(device_password) + selenium.find_by(By.ID, "sign-in-button").click() + selenium.find_by_xpath("//h1[text()='Applications']") + selenium.screenshot('2fa_recovery_cli') def test_settings_deactivate(selenium, device_host, full_domain, @@ -255,23 +386,37 @@ def test_settings_deactivate(selenium, device_host, full_domain, selenium.screenshot('activate-ready') selenium.find_by_id('btn_activate').click() wait_for_loading(selenium.driver) - selenium.find_by_xpath("//h1[text()='Log in']") - selenium.find_by_id("username").send_keys(device_user) - selenium.find_by_id("password").send_keys(device_password) - selenium.find_by_id("btn_login").click() - selenium.screenshot('index-progress') + # OIDC login via Authelia after reactivation + # Clear cookies on all domains to avoid stale session errors + selenium.driver.get("https://auth.{0}".format(full_domain)) + selenium.driver.delete_all_cookies() + selenium.driver.get("https://{0}".format(full_domain)) + selenium.driver.delete_all_cookies() + selenium.driver.get("https://{0}".format(device_host)) + selenium.driver.delete_all_cookies() + selenium.screenshot('deactivate-cookies-cleared') + # Use full_domain so the OIDC authorization flow initiates properly + wait_for_login(selenium, full_domain) + selenium.screenshot('deactivate-login-page') + selenium.find_by(By.ID, "username-textfield").send_keys(device_user) + selenium.find_by(By.ID, "password-textfield").send_keys(device_password) + selenium.find_by(By.ID, "sign-in-button").click() selenium.find_by_xpath("//h1[text()='Applications']") selenium.screenshot('reactivate-index') -def test_permission_denied(selenium, device, ui_mode): +def test_permission_denied(selenium, device, ui_mode, full_domain): device.run_ssh('/snap/platform/current/openldap/bin/ldapadd.sh -x -w syncloud -D "dc=syncloud,dc=org" -f /test/test.{0}.ldif'.format(ui_mode)) menu(selenium, 'logout') - selenium.find_by_xpath("//h1[text()='Log in']") - selenium.find_by_id("username").send_keys("test{0}".format(ui_mode)) - selenium.find_by_id("password").send_keys("password") - selenium.find_by_id("btn_login").click() - selenium.find_by_xpath("//div[contains(.,'not admin')]") + # Clear Authelia session so OIDC flow shows login page + selenium.driver.get("https://auth.{0}".format(full_domain)) + selenium.driver.delete_all_cookies() + # OIDC login via Authelia with non-admin user + selenium.driver.get("https://{0}".format(full_domain)) + selenium.find_by(By.ID, "username-textfield").send_keys("test{0}".format(ui_mode)) + selenium.find_by(By.ID, "password-textfield").send_keys("password") + selenium.find_by(By.ID, "sign-in-button").click() + time.sleep(2) selenium.screenshot('permission-denied') diff --git a/www/src/router/index.js b/www/src/router/index.js index 736981e21..6209bd17a 100644 --- a/www/src/router/index.js +++ b/www/src/router/index.js @@ -17,6 +17,7 @@ const routes = [ { path: '/support', name: 'Support', component: () => import('../views/Support.vue') }, { path: '/certificate', name: 'Certificate', component: () => import('../views/Certificate.vue') }, { path: '/certificate/log', name: 'Certificate Log', component: () => import('../views/CertificateLog.vue') }, + { path: '/twofactor', name: 'TwoFactor', component: () => import('../views/TwoFactor.vue') }, { path: '/logs', name: 'Logs', component: () => import('../views/Logs.vue') }, { path: '/:catchAll(.*)', redirect: '/' } ] diff --git a/www/src/stub/api.js b/www/src/stub/api.js index 6cf992041..830fd1dcb 100644 --- a/www/src/stub/api.js +++ b/www/src/stub/api.js @@ -237,14 +237,17 @@ export function mock () { author: Model }, routes () { - this.post('/rest/login', function (_schema, request) { + this.get('/rest/oidc/login', function (_schema, _request) { + state.loggedIn = true + return new Response(200, {}, { message: 'OK' }) + }) + this.get('/rest/settings/2fa', function (_schema, _request) { + return new Response(200, {}, { success: true, data: { enabled: state.twoFactorEnabled || false, authelia_url: 'https://auth.test.' + domain } }) + }) + this.post('/rest/settings/2fa', function (_schema, request) { const attrs = JSON.parse(request.requestBody) - if (state.credentials.username === attrs.username && state.credentials.password === attrs.password) { - state.loggedIn = true - return new Response(200, {}, { message: 'OK' }) - } else { - return new Response(400, {}, { message: 'Authentication failed' }) - } + state.twoFactorEnabled = attrs.enabled + return new Response(200, {}, { success: true, data: 'OK' }) }) this.get('/rest/user', function (_schema, _request) { if (!state.activated) { diff --git a/www/src/views/Login.vue b/www/src/views/Login.vue index 94362c967..bd720faff 100644 --- a/www/src/views/Login.vue +++ b/www/src/views/Login.vue @@ -1,28 +1,10 @@ - Log in - - - - - Log in - - + Redirecting to login... @@ -34,8 +16,6 @@ diff --git a/www/src/views/Settings.vue b/www/src/views/Settings.vue index 75c7152df..26b6ff3e2 100644 --- a/www/src/views/Settings.vue +++ b/www/src/views/Settings.vue @@ -68,6 +68,13 @@ + + + verified_user + 2FA + + + feed diff --git a/www/src/views/TwoFactor.vue b/www/src/views/TwoFactor.vue new file mode 100644 index 000000000..889ff46cb --- /dev/null +++ b/www/src/views/TwoFactor.vue @@ -0,0 +1,101 @@ + + + + + Two-Factor Authentication + + + Status + {{ enabled ? 'Enabled' : 'Disabled' }} + + + + Before enabling 2FA, register your authenticator device in Authelia settings. + + Open Authelia Settings + + + + + Enable 2FA + Disable 2FA + + + + + + + + + + + + diff --git a/www/tests/unit/Login.spec.js b/www/tests/unit/Login.spec.js index 91e52516e..47e0c9bdd 100644 --- a/www/tests/unit/Login.spec.js +++ b/www/tests/unit/Login.spec.js @@ -1,26 +1,12 @@ import { mount } from '@vue/test-utils' -import axios from 'axios' -import MockAdapter from 'axios-mock-adapter' import flushPromises from 'flush-promises' import Login from '../../src/views/Login.vue' -import { ElButton } from 'element-plus' jest.setTimeout(30000) -test('Login', async () => { - const showError = jest.fn() - const mockRouter = { push: jest.fn() } - - let username - let password - - const mock = new MockAdapter(axios) - mock.onPost('/rest/login').reply(function (config) { - const request = JSON.parse(config.data) - username = request.username - password = request.password - return [200, { success: true }] - }) +test('Login redirects to OIDC', async () => { + delete window.location + window.location = { href: '' } const wrapper = mount(Login, { @@ -34,14 +20,9 @@ test('Login', async () => { Error: { template: '', methods: { - showAxios: showError + showAxios: jest.fn() } - }, - 'el-button': ElButton - }, - mocks: { - $route: { path: '/login' }, - $router: mockRouter + } } } } @@ -49,16 +30,7 @@ test('Login', async () => { await flushPromises() - await wrapper.find('#username').setValue('username') - await wrapper.find('#password').setValue('password') - await wrapper.find('#btn_login').trigger('click') - - await flushPromises() - - expect(showError).toHaveBeenCalledTimes(0) - expect(username).toBe('username') - expect(password).toBe('password') - expect(mockRouter.push).toHaveBeenCalledWith('/') + expect(window.location.href).toBe('/rest/oidc/login') wrapper.unmount() })
Redirecting to login...
Before enabling 2FA, register your authenticator device in Authelia settings.