From cf16272a730c9aa80df3965eec3697790110d26d Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sun, 3 Sep 2017 15:22:09 +0200 Subject: [PATCH] Refine access control with per resource ACLs ACLs can now be defined by subdomain AND resource using pattern matching with regular expressions. It allows a very fine-grained access control to backend resources. [Note] For using example environmnent, user must update its /etc/hosts with new subdomains updated in README. --- .travis.yml | 5 +- README.md | 61 ++- config.template.yml | 90 +++- config.test.yml | 54 ++- example/nginx/docker-compose.yml | 8 +- .../html/{ => admin.test.local}/secret.html | 0 .../dev.test.local/groups/admin/secret.html | 10 + .../dev.test.local/groups/dev/secret.html | 10 + .../html/dev.test.local/users/bob/secret.html | 10 + .../dev.test.local/users/harry/secret.html | 10 + .../dev.test.local/users/john/secret.html | 10 + example/nginx/html/home.test.local/index.html | 132 ++++++ example/nginx/html/index.html | 82 ---- .../nginx/html/mail.test.local/secret.html | 10 + .../nginx/html/public.test.local/index.html | 13 + .../nginx/html/public.test.local/secret.html | 10 + example/nginx/nginx.conf | 127 +++++- .../lib/access_control/AccessController.ts | 124 ++++-- .../lib/access_control/DomainMatcher.ts | 12 + .../lib/access_control/IAccessController.ts | 4 + .../lib/access_control/PatternBuilder.ts | 61 --- .../lib/configuration/Configuration.d.ts | 29 +- src/server/lib/routes/verify/get.ts | 9 +- test/features/access-control.feature | 57 +-- test/features/authentication.feature | 10 +- test/features/redirection.feature | 7 +- test/features/resilience.feature | 6 +- .../step_definitions/authentication.ts | 2 +- test/features/support/world.ts | 3 + .../SessionConfigurationBuilder.test.ts | 6 +- .../access_control/AccessController.test.ts | 386 ++++++++++++++++-- .../access_control/PatternBuilder.test.ts | 120 ------ .../ConfigurationAdapter.test.ts | 13 +- test/unit/server/mocks/AccessController.ts | 12 - .../unit/server/mocks/AccessControllerStub.ts | 15 + .../server/routes/firstfactor/post.test.ts | 8 +- test/unit/server/routes/verify/get.test.ts | 12 +- 37 files changed, 1043 insertions(+), 495 deletions(-) rename example/nginx/html/{ => admin.test.local}/secret.html (100%) create mode 100644 example/nginx/html/dev.test.local/groups/admin/secret.html create mode 100644 example/nginx/html/dev.test.local/groups/dev/secret.html create mode 100644 example/nginx/html/dev.test.local/users/bob/secret.html create mode 100644 example/nginx/html/dev.test.local/users/harry/secret.html create mode 100644 example/nginx/html/dev.test.local/users/john/secret.html create mode 100644 example/nginx/html/home.test.local/index.html delete mode 100644 example/nginx/html/index.html create mode 100644 example/nginx/html/mail.test.local/secret.html create mode 100644 example/nginx/html/public.test.local/index.html create mode 100644 example/nginx/html/public.test.local/secret.html create mode 100644 src/server/lib/access_control/DomainMatcher.ts create mode 100644 src/server/lib/access_control/IAccessController.ts delete mode 100644 src/server/lib/access_control/PatternBuilder.ts delete mode 100644 test/unit/server/access_control/PatternBuilder.test.ts delete mode 100644 test/unit/server/mocks/AccessController.ts create mode 100644 test/unit/server/mocks/AccessControllerStub.ts diff --git a/.travis.yml b/.travis.yml index d69b39f4..ab6c78ea 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,9 +18,8 @@ addons: - auth.test.local - home.test.local - public.test.local - - secret.test.local - - secret1.test.local - - secret2.test.local + - admin.test.local + - dev.test.local - mx1.mail.test.local - mx2.mail.test.local diff --git a/README.md b/README.md index 25c99c96..778d2929 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ without even configure anything. Otherwise here are the available steps to deploy **Authelia** on your machine given your configuration file is **/path/to/your/config.yml**. Note that you can create your -own the configuration file from **config.template.yml** at the root of the repo. +own the configuration file from [config.template.yml] at the root of the repo. ### With NPM @@ -91,11 +91,10 @@ Make sure you don't have anything listening on port 8080. Add the following lines to your **/etc/hosts** to alias multiple subdomains so that nginx can redirect request to the correct virtual host. - 127.0.0.1 public.test.local - 127.0.0.1 secret.test.local - 127.0.0.1 secret1.test.local - 127.0.0.1 secret2.test.local 127.0.0.1 home.test.local + 127.0.0.1 public.test.local + 127.0.0.1 dev.test.local + 127.0.0.1 admin.test.local 127.0.0.1 mx1.mail.test.local 127.0.0.1 mx2.mail.test.local 127.0.0.1 auth.test.local @@ -119,7 +118,7 @@ After few seconds the services should be running and you should be able to visit When accessing the login page, a self-signed certificate exception should appear, it has to be trusted before you can get to the target page. The certificate -must be trusted for each subdomain, therefore it is normal to see the exception +must also be trusted for each subdomain, therefore it is normal to see the exception several times. Below is what the login page looks like: @@ -128,37 +127,24 @@ Below is what the login page looks like: ## Features in details -### First factor with LDAP and ACL -An LDAP server has been deployed for you with the following credentials and -access control list: - -- **john / password** is in the admin group and has access to the secret from -any subdomain. -- **bob / password** is in the dev group and has access to the secret from - - [secret.test.local](https://secret.test.local:8080/secret.html) - - [secret2.test.local](https://secret2.test.local:8080/secret.html) - - [home.test.local](https://home.test.local:8080/secret.html) - - [\*.mail.test.local](https://mx1.mail.test.local:8080/secret.html) -- **harry / password** is not in a group but has rules giving him has access to - the secret from - - [secret1.test.local](https://secret1.test.local:8080/secret.html) - - [home.test.local](https://home.test.local:8080/secret.html) - -You can use them in the login page. If everything is ok, the second factor -page should appear as shown below. Otherwise you'll get an error message notifying -your credentials are wrong. +### First factor using an LDAP server +**Authelia** uses an LDAP server as the backend for storing credentials. +When authentication is needed, the user is redirected to the login page which +corresponds to the first factor. Authelia tries to bind the username and password +against the configured LDAP backend. +You can find an example of the configuration of the LDAP backend in [config.template.yml]. ### Second factor with TOTP -In **Authelia**, you need to register a per user TOTP (Time-Based One Time Password) secret before +In **Authelia**, you can register a per user TOTP (Time-Based One Time Password) secret before authenticating. To do that, you need to click on the register button. It will -send a link to the user email address. Since this is an example, no email will -be sent, the link is rather delivered in the file +send a link to the user email address defined in the LDAP. +Since this is an example, no email will be sent, the link is rather delivered in the file **/tmp/notifications/notification.txt**. Paste the link in your browser and you'll get -your secret in QRCode and Base32 formats. You can use +your secret in QRCode and Base32 format. You can use [Google Authenticator] to store them and get the generated tokens with the app. @@ -169,8 +155,8 @@ to store them and get the generated tokens with the app. USB security keys. U2F is one of the most secure authentication protocol and is already available for Google, Facebook, Github accounts and more. -Like TOTP, U2F requires you register your security key before authenticating. -To do so, click on the register button. This will send a link to the +Like TOTP, U2F requires you register a security key before authenticating. +To do so, click on the register link. This will send a link to the user email address. Since this is an example, no email will be sent, the link is rather delivered in the file **/tmp/notifications/notification.txt**. Paste the link in your browser and you'll be asking to touch the token of your device @@ -190,18 +176,18 @@ Paste the link in your browser and you should be able to reset the password. ### Access Control -With **Authelia**, you can define your own access control rules for restricting -the user access to some subdomains. Those rules are defined in the -configuration file and can be set either for everyone, per-user or per-group policies. -Check out the *config.template.yml* to see how they are defined. +With **Authelia**, you can define your own access control rules for finely restricting +user access to some resources and subdomains. Those rules are defined and fully documented +in the configuration file. They can apply to users, groups or everyone. +Check out [config.template.yml] to see how they are defined. ### Session management with Redis -When your users authenticate against Authelia, sessions are stored in a Redis key/value store. You can specify your own Redis instance in the [configuration file](#authelia-configuration). +When your users authenticate against Authelia, sessions are stored in a Redis key/value store. You can specify your own Redis instance in [config.template.yml]. ## Documentation ### Authelia configuration The configuration of the server is defined in the file -**configuration.template.yml**. All the details are documented there. +[config.template.yml]. All the details are documented there. You can specify another configuration file by giving it as first argument of **Authelia**. @@ -246,4 +232,5 @@ Follow [contributing](CONTRIBUTORS.md) file. [Yubikey]: https://www.yubico.com/products/yubikey-hardware/yubikey4/ [auth_request]: http://nginx.org/en/docs/http/ngx_http_auth_request_module.html [Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en +[config.template.yml]: https://github.com/clems4ever/authelia/blob/master/config.template.yml diff --git a/config.template.yml b/config.template.yml index 2bcc1c92..09404d09 100644 --- a/config.template.yml +++ b/config.template.yml @@ -49,38 +49,84 @@ ldap: # Access Control # -# Access control is a set of rules you can use to restrict the user access. -# Default (anyone), per-user or per-group rules can be defined. +# Access control is a set of rules you can use to restrict user access to certain +# resources. +# Any (apply to anyone), per-user or per-group rules can be defined. # -# If 'access_control' is not defined, ACL rules are disabled and a default policy -# is applied, i.e., access is allowed to anyone. Otherwise restrictions follow -# the rules defined below. -# If no rule is provided, all domains are denied. +# If 'access_control' is not defined, ACL rules are disabled and the `allow` default +# policy is applied, i.e., access is allowed to anyone. Otherwise restrictions follow +# the rules defined. +# +# Note: One can use the wildcard * to match any subdomain. +# It must stand at the beginning of the pattern. (example: *.mydomain.com) +# +# Note: You must put the pattern in simple quotes when using the wildcard for the YAML +# to be syntaxically correct. +# +# Definition: A `rule` is an object with the following keys: `domain`, `policy` +# and `resources`. +# - `domain` defines which domain or set of domains the rule applies to. +# - `policy` is the policy to apply to resources. It must be either `allow` or `deny`. +# - `resources` is a list of regular expressions that matches a set of resources to +# apply the policy to. +# +# Note: Rules follow an order of priority defined as follows: +# In each category (`any`, `groups`, `users`), the latest rules have the highest +# priority. In other words, it means that if a given resource matches two rules in the +# same category, the latest one overrides the first one. +# Each category has also its own priority. That is, `users` has the highest priority, then +# `groups` and `any` has the lowest priority. It means if two rules in different categories +# match a given resource, the one in the category with the highest priority overrides the +# other one. # -# One can use the wildcard * to match any subdomain. -# Note 1: It must stand at the beginning of the pattern. (example: *.mydomain.com) -# Note 2: You must put the pattern in simple quotes when using the wildcard. access_control: - # The default policy. Applies to any user - default: - - public.test.local + # Default policy can either be `allow` or `deny`. + # It is the policy applied to any resource if it has not been overriden + # in the `any`, `groups` or `users` category. + default_policy: deny + + # The rules that apply to anyone. + # The value is a list of rules. + any: + - domain: public.test.local + policy: allow - # Group based policies. The key is a group name and the value - # is the domain to allow access to. + # Group-based rules. The key is a group name and the value + # is a list of rules. groups: admin: - - '*.test.local' + # All resources in all domains + - domain: '*.test.local' + policy: allow + # Except mx2.mail.test.local (it restricts the first rule) + - domain: 'mx2.mail.test.local' + policy: deny dev: - - secret.test.local - - secret2.test.local + - domain: dev.test.local + policy: allow + resources: + - '^/groups/dev/.*$' - # Group based policies. The key is a group name and the value - # is the domain to allow access to. - users: + # User-based rules. The key is a user name and the value + # is a list of rules. + users: + john: + - domain: dev.test.local + policy: allow + resources: + - '^/users/john/.*$' harry: - - secret1.test.local + - domain: dev.test.local + policy: allow + resources: + - '^/users/harry/.*$' bob: - - '*.mail.test.local' + - domain: '*.mail.test.local' + policy: allow + - domain: 'dev.test.local' + policy: allow + resources: + - '^/users/bob/.*$' # Configuration of session cookies diff --git a/config.test.yml b/config.test.yml index 557cf5d4..f297b025 100644 --- a/config.test.yml +++ b/config.test.yml @@ -61,27 +61,53 @@ ldap: # Note 1: It must stand at the beginning of the pattern. (example: *.mydomain.com) # Note 2: You must put the pattern in simple quotes when using the wildcard. access_control: - # The default policy. Applies to any user - default: - - public.test.local + # Default policy can either be `allow` or `deny`. + # It is the policy applied to any resource if it has not been overriden + # in the `any`, `groups` or `users` category. + default_policy: deny + + # The rules that apply to anyone. + # The value is a list of rules. + any: + - domain: public.test.local + policy: allow - # Group based policies. The key is a group name and the value - # is the domain to allow access to. + # Group-based rules. The key is a group name and the value + # is a list of rules. groups: admin: - - '*.test.local' + # All resources in all domains + - domain: '*.test.local' + policy: allow + # Except mx2.mail.test.local (it restricts the first rule) + - domain: 'mx2.mail.test.local' + policy: deny dev: - - secret.test.local - - secret2.test.local + - domain: dev.test.local + policy: allow + resources: + - '^/groups/dev/.*$' - # Group based policies. The key is a group name and the value - # is the domain to allow access to. - users: + # User-based rules. The key is a user name and the value + # is a list of rules. + users: + john: + - domain: dev.test.local + policy: allow + resources: + - '^/users/john/.*$' harry: - - secret1.test.local + - domain: dev.test.local + policy: allow + resources: + - '^/users/harry/.*$' bob: - - '*.mail.test.local' - + - domain: '*.mail.test.local' + policy: allow + - domain: 'dev.test.local' + policy: allow + resources: + - '^/users/bob/.*$' # Configuration of session cookies # diff --git a/example/nginx/docker-compose.yml b/example/nginx/docker-compose.yml index 532c3a89..712a23b6 100644 --- a/example/nginx/docker-compose.yml +++ b/example/nginx/docker-compose.yml @@ -14,10 +14,8 @@ services: - example-network # aliases: # - home.test.local - # - secret.test.local - # - secret1.test.local - # - secret2.test.local - # - mx1.mail.test.local - # - mx2.mail.test.local + # - public.test.local + # - admin.test.local + # - dev.test.local # - auth.test.local diff --git a/example/nginx/html/secret.html b/example/nginx/html/admin.test.local/secret.html similarity index 100% rename from example/nginx/html/secret.html rename to example/nginx/html/admin.test.local/secret.html diff --git a/example/nginx/html/dev.test.local/groups/admin/secret.html b/example/nginx/html/dev.test.local/groups/admin/secret.html new file mode 100644 index 00000000..386bd893 --- /dev/null +++ b/example/nginx/html/dev.test.local/groups/admin/secret.html @@ -0,0 +1,10 @@ + + + Secret + + + + This is a very important secret!
+ Go back to home page. + + diff --git a/example/nginx/html/dev.test.local/groups/dev/secret.html b/example/nginx/html/dev.test.local/groups/dev/secret.html new file mode 100644 index 00000000..386bd893 --- /dev/null +++ b/example/nginx/html/dev.test.local/groups/dev/secret.html @@ -0,0 +1,10 @@ + + + Secret + + + + This is a very important secret!
+ Go back to home page. + + diff --git a/example/nginx/html/dev.test.local/users/bob/secret.html b/example/nginx/html/dev.test.local/users/bob/secret.html new file mode 100644 index 00000000..386bd893 --- /dev/null +++ b/example/nginx/html/dev.test.local/users/bob/secret.html @@ -0,0 +1,10 @@ + + + Secret + + + + This is a very important secret!
+ Go back to home page. + + diff --git a/example/nginx/html/dev.test.local/users/harry/secret.html b/example/nginx/html/dev.test.local/users/harry/secret.html new file mode 100644 index 00000000..386bd893 --- /dev/null +++ b/example/nginx/html/dev.test.local/users/harry/secret.html @@ -0,0 +1,10 @@ + + + Secret + + + + This is a very important secret!
+ Go back to home page. + + diff --git a/example/nginx/html/dev.test.local/users/john/secret.html b/example/nginx/html/dev.test.local/users/john/secret.html new file mode 100644 index 00000000..386bd893 --- /dev/null +++ b/example/nginx/html/dev.test.local/users/john/secret.html @@ -0,0 +1,10 @@ + + + Secret + + + + This is a very important secret!
+ Go back to home page. + + diff --git a/example/nginx/html/home.test.local/index.html b/example/nginx/html/home.test.local/index.html new file mode 100644 index 00000000..a96dee5c --- /dev/null +++ b/example/nginx/html/home.test.local/index.html @@ -0,0 +1,132 @@ + + + + + Home page + + + + +

Access the secret

+ You need to log in to access the secret!

+ Try to access it using one of the following links to test access control powered by Authelia.
+ + + You can also log off by visiting the following link. + +

List of users

+ Here is the list of credentials you can log in with to test access control.
+
+ Once first factor is passed, you will need to follow the links to register a secret for the second factor.
+ Authelia will send you a fictituous email that will be in the file + /tmp/notifications/notification.txt.
+ It will provide you with the link to complete the registration allowing you to authenticate with 2-factor. + + + +

Access control rules

+

These rules are extracted from the configuration file + config.template.yml.

+
+# Default policy can either be `allow` or `deny`.
+# It is the policy applied to any resource if it has not been overriden
+# in the `any`, `groups` or `users` category.
+
+default_policy: deny
+
+# The rules that apply to anyone.
+# The value is a list of rules.
+
+any:
+  - domain: public.test.local
+    policy: allow
+
+# Group-based rules. The key is a group name and the value
+# is a list of rules.
+
+groups:
+  admin:
+    # All resources in all domains
+    - domain: '*.test.local'
+      policy: allow
+    # Except mx2.mail.test.local (it restricts the first rule)
+    - domain: 'mx2.mail.test.local'
+      policy: deny
+  dev:
+    - domain: dev.test.local
+      policy: allow
+      resources:
+        - '^/groups/dev/.*$'
+
+# User-based rules. The key is a user name and the value
+# is a list of rules.
+
+users:
+  john:
+    - domain: dev.test.local
+      policy: allow
+      resources:
+        - '^/users/john/.*$' 
+  harry:
+    - domain: dev.test.local
+      policy: allow
+      resources:
+        - '^/users/harry/.*$'
+  bob:
+    - domain: '*.mail.test.local'
+      policy: allow
+    - domain: 'dev.test.local'
+      policy: allow
+      resources:
+        - '^/users/bob/.*$'
+    - domain: 'dev.test.local'
+      policy: allow
+      resources:
+        - '^/users/harry/.*$'
+ + \ No newline at end of file diff --git a/example/nginx/html/index.html b/example/nginx/html/index.html deleted file mode 100644 index f009d515..00000000 --- a/example/nginx/html/index.html +++ /dev/null @@ -1,82 +0,0 @@ - - - - Home page - - - -

Access the secret

- You need to log in to access the secret!

- Try to access it via one of the following links.
- - - You can also log off by visiting the following link. - -

List of users

- Here is the list of credentials you can log in with to test access control. - - - -

Access control rules

- - - - diff --git a/example/nginx/html/mail.test.local/secret.html b/example/nginx/html/mail.test.local/secret.html new file mode 100644 index 00000000..386bd893 --- /dev/null +++ b/example/nginx/html/mail.test.local/secret.html @@ -0,0 +1,10 @@ + + + Secret + + + + This is a very important secret!
+ Go back to home page. + + diff --git a/example/nginx/html/public.test.local/index.html b/example/nginx/html/public.test.local/index.html new file mode 100644 index 00000000..d5629c05 --- /dev/null +++ b/example/nginx/html/public.test.local/index.html @@ -0,0 +1,13 @@ + + + + Public resource + + + +

Public resource

+

This is a public resource.
+ Go back to home page. +

+ + diff --git a/example/nginx/html/public.test.local/secret.html b/example/nginx/html/public.test.local/secret.html new file mode 100644 index 00000000..386bd893 --- /dev/null +++ b/example/nginx/html/public.test.local/secret.html @@ -0,0 +1,10 @@ + + + Secret + + + + This is a very important secret!
+ Go back to home page. + + diff --git a/example/nginx/nginx.conf b/example/nginx/nginx.conf index 9d5f0ebe..18e01224 100644 --- a/example/nginx/nginx.conf +++ b/example/nginx/nginx.conf @@ -50,11 +50,20 @@ http { server { listen 443 ssl; - root /usr/share/nginx/html; + root /usr/share/nginx/html/home.test.local; - server_name secret1.test.local secret2.test.local secret.test.local - home.test.local mx1.mail.test.local mx2.mail.test.local - public.test.local; + server_name home.test.local; + + ssl on; + ssl_certificate /etc/ssl/server.crt; + ssl_certificate_key /etc/ssl/server.key; + } + + server { + listen 443 ssl; + root /usr/share/nginx/html/public.test.local; + + server_name public.test.local; ssl on; ssl_certificate /etc/ssl/server.crt; @@ -69,7 +78,115 @@ http { proxy_pass http://authelia/verify; } - location = /secret.html { + location / { + auth_request /auth_verify; + + auth_request_set $redirect $upstream_http_redirect; + proxy_set_header Redirect $redirect; + + auth_request_set $user $upstream_http_remote_user; + proxy_set_header X-Forwarded-User $user; + + auth_request_set $groups $upstream_http_remote_groups; + proxy_set_header Remote-Groups $groups; + + error_page 401 =302 https://auth.test.local:8080?redirect=$redirect; + error_page 403 = https://auth.test.local:8080/error/403; + } + } + + server { + listen 443 ssl; + root /usr/share/nginx/html/admin.test.local; + + server_name admin.test.local; + + ssl on; + ssl_certificate /etc/ssl/server.crt; + ssl_certificate_key /etc/ssl/server.key; + + location /auth_verify { + internal; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $http_host; + + proxy_pass http://authelia/verify; + } + + location / { + auth_request /auth_verify; + + auth_request_set $redirect $upstream_http_redirect; + proxy_set_header Redirect $redirect; + + auth_request_set $user $upstream_http_remote_user; + proxy_set_header X-Forwarded-User $user; + + auth_request_set $groups $upstream_http_remote_groups; + proxy_set_header Remote-Groups $groups; + + error_page 401 =302 https://auth.test.local:8080?redirect=$redirect; + error_page 403 = https://auth.test.local:8080/error/403; + } + } + + server { + listen 443 ssl; + root /usr/share/nginx/html/dev.test.local; + + server_name dev.test.local; + + ssl on; + ssl_certificate /etc/ssl/server.crt; + ssl_certificate_key /etc/ssl/server.key; + + location /auth_verify { + internal; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $http_host; + + proxy_pass http://authelia/verify; + } + + location / { + auth_request /auth_verify; + + auth_request_set $redirect $upstream_http_redirect; + proxy_set_header Redirect $redirect; + + auth_request_set $user $upstream_http_remote_user; + proxy_set_header X-Forwarded-User $user; + + auth_request_set $groups $upstream_http_remote_groups; + proxy_set_header Remote-Groups $groups; + + error_page 401 =302 https://auth.test.local:8080?redirect=$redirect; + error_page 403 = https://auth.test.local:8080/error/403; + } + } + + server { + listen 443 ssl; + root /usr/share/nginx/html/mail.test.local; + + server_name mx1.mail.test.local mx2.mail.test.local; + + ssl on; + ssl_certificate /etc/ssl/server.crt; + ssl_certificate_key /etc/ssl/server.key; + + location /auth_verify { + internal; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $http_host; + + proxy_pass http://authelia/verify; + } + + location / { auth_request /auth_verify; auth_request_set $redirect $upstream_http_redirect; diff --git a/src/server/lib/access_control/AccessController.ts b/src/server/lib/access_control/AccessController.ts index c23157ff..9408768c 100644 --- a/src/server/lib/access_control/AccessController.ts +++ b/src/server/lib/access_control/AccessController.ts @@ -1,35 +1,109 @@ -import { ACLConfiguration } from "../configuration/Configuration"; -import PatternBuilder from "./PatternBuilder"; +import { ACLConfiguration, ACLPolicy, ACLRule } from "../configuration/Configuration"; +import { IAccessController } from "./IAccessController"; import { Winston } from "../../../types/Dependencies"; +import { DomainMatcher } from "./DomainMatcher"; -export class AccessController { - private logger: Winston; - private patternBuilder: PatternBuilder; - constructor(configuration: ACLConfiguration, logger_: Winston) { - this.logger = logger_; - this.patternBuilder = new PatternBuilder(configuration, logger_); +enum AccessReturn { + NO_MATCHING_RULES, + MATCHING_RULES_AND_ACCESS, + MATCHING_RULES_AND_NO_ACCESS +} + +function AllowedRule(rule: ACLRule) { + return rule.policy == "allow"; +} + +function MatchDomain(actualDomain: string) { + return function (rule: ACLRule): boolean { + return DomainMatcher.match(actualDomain, rule.domain); + }; +} + +function MatchResource(actualResource: string) { + return function (rule: ACLRule): boolean { + // If resources key is not provided, the rule applies to all resources. + if (!rule.resources) return true; + + for (let i = 0; i < rule.resources.length; ++i) { + const regexp = new RegExp(rule.resources[i]); + if (regexp.test(actualResource)) return true; } + return false; + }; +} - isDomainAllowedForUser(domain: string, user: string, groups: string[]): boolean { - const allowed_domains = this.patternBuilder.getAllowedDomains(user, groups); +function SelectPolicy(rule: ACLRule): ("allow" | "deny") { + return rule.policy; +} - // Allow all matcher - if (allowed_domains.length == 1 && allowed_domains[0] == "*") return true; +export class AccessController implements IAccessController { + private logger: Winston; + private readonly configuration: ACLConfiguration; - this.logger.debug("ACL: trying to match %s with %s", domain, - JSON.stringify(allowed_domains)); - for (let i = 0; i < allowed_domains.length; ++i) { - const allowed_domain = allowed_domains[i]; - if (allowed_domain.startsWith("*") && - domain.endsWith(allowed_domain.substr(1))) { - return true; - } - else if (domain == allowed_domain) { - return true; - } - } - return false; + constructor(configuration: ACLConfiguration, logger_: Winston) { + this.logger = logger_; + this.configuration = configuration; + } + + private isAccessAllowedInRules(rules: ACLRule[], domain: string, resource: string): AccessReturn { + if (!rules) + return AccessReturn.NO_MATCHING_RULES; + + const policies = rules.map(SelectPolicy); + + if (rules.length > 0) { + if (policies[0] == "allow") { + return AccessReturn.MATCHING_RULES_AND_ACCESS; + } + else { + return AccessReturn.MATCHING_RULES_AND_NO_ACCESS; + } } + return AccessReturn.NO_MATCHING_RULES; + } + + private getMatchingUserRules(user: string, domain: string, resource: string): ACLRule[] { + const userRules = this.configuration.users[user]; + if (!userRules) return []; + return userRules.filter(MatchDomain(domain)).filter(MatchResource(resource)); + } + + private getMatchingGroupRules(groups: string[], domain: string, resource: string): ACLRule[] { + const that = this; + // There is no ordering between group rules. That is, when a user belongs to 2 groups, there is no + // guarantee one set of rules has precedence on the other one. + const groupRules = groups.reduce(function (rules: ACLRule[], group: string) { + const groupRules = that.configuration.groups[group]; + if (groupRules) rules = rules.concat(groupRules); + return rules; + }, []); + return groupRules.filter(MatchDomain(domain)).filter(MatchResource(resource)); + } + + private getMatchingAllRules(domain: string, resource: string): ACLRule[] { + const rules = this.configuration.any; + if (!rules) return []; + return rules.filter(MatchDomain(domain)).filter(MatchResource(resource)); + } + + private isAccessAllowedDefaultPolicy(): boolean { + return this.configuration.default_policy == "allow"; + } + + isAccessAllowed(domain: string, resource: string, user: string, groups: string[]): boolean { + const allRules = this.getMatchingAllRules(domain, resource); + const groupRules = this.getMatchingGroupRules(groups, domain, resource); + const userRules = this.getMatchingUserRules(user, domain, resource); + const rules = allRules.concat(groupRules).concat(userRules).reverse(); + + const access = this.isAccessAllowedInRules(rules, domain, resource); + if (access == AccessReturn.MATCHING_RULES_AND_ACCESS) + return true; + else if (access == AccessReturn.MATCHING_RULES_AND_NO_ACCESS) + return false; + + return this.isAccessAllowedDefaultPolicy(); + } } \ No newline at end of file diff --git a/src/server/lib/access_control/DomainMatcher.ts b/src/server/lib/access_control/DomainMatcher.ts new file mode 100644 index 00000000..2afb14a3 --- /dev/null +++ b/src/server/lib/access_control/DomainMatcher.ts @@ -0,0 +1,12 @@ + +export class DomainMatcher { + static match(domain: string, allowedDomain: string): boolean { + if (allowedDomain.startsWith("*") && + domain.endsWith(allowedDomain.substr(1))) { + return true; + } + else if (domain == allowedDomain) { + return true; + } + } +} \ No newline at end of file diff --git a/src/server/lib/access_control/IAccessController.ts b/src/server/lib/access_control/IAccessController.ts new file mode 100644 index 00000000..83681b89 --- /dev/null +++ b/src/server/lib/access_control/IAccessController.ts @@ -0,0 +1,4 @@ + +export interface IAccessController { + isAccessAllowed(domain: string, resource: string, user: string, groups: string[]): boolean; +} \ No newline at end of file diff --git a/src/server/lib/access_control/PatternBuilder.ts b/src/server/lib/access_control/PatternBuilder.ts deleted file mode 100644 index 17d4f7e6..00000000 --- a/src/server/lib/access_control/PatternBuilder.ts +++ /dev/null @@ -1,61 +0,0 @@ - -import { Winston } from "../../../types/Dependencies"; -import { ACLConfiguration, ACLGroupsRules, ACLUsersRules, ACLDefaultRules } from "../configuration/Configuration"; -import objectPath = require("object-path"); - -export default class AccessControlPatternBuilder { - logger: Winston; - configuration: ACLConfiguration; - - constructor(configuration: ACLConfiguration | undefined, logger_: Winston) { - this.configuration = configuration; - this.logger = logger_; - } - - private buildFromGroups(groups: string[]): string[] { - let allowed_domains: string[] = []; - const groups_policy = objectPath.get(this.configuration, "groups"); - if (groups_policy) { - for (let i = 0; i < groups.length; ++i) { - const group = groups[i]; - if (group in groups_policy) { - const group_policy: string[] = groups_policy[group]; - allowed_domains = allowed_domains.concat(groups_policy[group]); - } - } - } - return allowed_domains; - } - - private buildFromUser(user: string): string[] { - let allowed_domains: string[] = []; - const users_policy = objectPath.get(this.configuration, "users"); - if (users_policy) { - if (user in users_policy) { - allowed_domains = allowed_domains.concat(users_policy[user]); - } - } - return allowed_domains; - } - - getAllowedDomains(user: string, groups: string[]): string[] { - if (!this.configuration) { - this.logger.debug("No access control rules found." + - "Default policy to allow all."); - return ["*"]; // No configuration means, no restrictions. - } - - let allowed_domains: string[] = []; - const default_policy = objectPath.get(this.configuration, "default"); - if (default_policy) { - allowed_domains = allowed_domains.concat(default_policy); - } - - allowed_domains = allowed_domains.concat(this.buildFromGroups(groups)); - allowed_domains = allowed_domains.concat(this.buildFromUser(user)); - - this.logger.debug("ACL: user \'%s\' is allowed access to %s", user, - JSON.stringify(allowed_domains)); - return allowed_domains; - } -} diff --git a/src/server/lib/configuration/Configuration.d.ts b/src/server/lib/configuration/Configuration.d.ts index 5c3c8e32..cdfb029d 100644 --- a/src/server/lib/configuration/Configuration.d.ts +++ b/src/server/lib/configuration/Configuration.d.ts @@ -1,32 +1,32 @@ export interface UserLdapConfiguration { url: string; base_dn: string; - + additional_users_dn?: string; users_filter?: string; additional_groups_dn?: string; groups_filter?: string; - + group_name_attribute?: string; mail_attribute?: string; - + user: string; // admin username password: string; // admin password } export interface LdapConfiguration { url: string; - + users_dn: string; users_filter: string; groups_dn: string; groups_filter: string; - + group_name_attribute: string; mail_attribute: string; - + user: string; // admin username password: string; // admin password } @@ -35,12 +35,21 @@ type UserName = string; type GroupName = string; type DomainPattern = string; -export type ACLDefaultRules = DomainPattern[]; -export type ACLGroupsRules = { [group: string]: string[]; }; -export type ACLUsersRules = { [user: string]: string[]; }; +export type ACLPolicy = 'deny' | 'allow'; + +export type ACLRule = { + domain: string; + policy: ACLPolicy; + resources?: string[]; +} + +export type ACLDefaultRules = ACLRule[]; +export type ACLGroupsRules = { [group: string]: ACLRule[]; }; +export type ACLUsersRules = { [user: string]: ACLRule[]; }; export interface ACLConfiguration { - default: ACLDefaultRules; + default_policy: ACLPolicy; + any: ACLDefaultRules; groups: ACLGroupsRules; users: ACLUsersRules; } diff --git a/src/server/lib/routes/verify/get.ts b/src/server/lib/routes/verify/get.ts index d0a53fab..5705b855 100644 --- a/src/server/lib/routes/verify/get.ts +++ b/src/server/lib/routes/verify/get.ts @@ -6,7 +6,7 @@ import exceptions = require("../../Exceptions"); import winston = require("winston"); import AuthenticationValidator = require("../../AuthenticationValidator"); import ErrorReplies = require("../../ErrorReplies"); -import {  ServerVariablesHandler } from "../../ServerVariablesHandler"; +import { ServerVariablesHandler } from "../../ServerVariablesHandler"; import AuthenticationSession = require("../../AuthenticationSession"); function verify_filter(req: express.Request, res: express.Response): BluebirdPromise { @@ -27,10 +27,11 @@ function verify_filter(req: express.Request, res: express.Response): BluebirdPro const groups = authSession.groups; const host = objectPath.get(req, "headers.host"); - const domain = host.split(":")[0]; - console.log(domain); + const path = objectPath.get(req, "headers.x-original-uri"); - const isAllowed = accessController.isDomainAllowedForUser(domain, username, groups); + const domain = host.split(":")[0]; + + const isAllowed = accessController.isAccessAllowed(domain, path, username, groups); if (!isAllowed) return BluebirdPromise.reject( new exceptions.DomainAccessDenied("User '" + username + "' does not have access to " + domain)); diff --git a/test/features/access-control.feature b/test/features/access-control.feature index 7f166351..d760cbf0 100644 --- a/test/features/access-control.feature +++ b/test/features/access-control.feature @@ -7,13 +7,18 @@ Feature: User has access restricted access to domains And I use "REGISTERED" as TOTP token handle And I click on "TOTP" Then I have access to: - | url | - | https://public.test.local:8080/secret.html | - | https://secret.test.local:8080/secret.html | - | https://secret1.test.local:8080/secret.html | - | https://secret2.test.local:8080/secret.html | - | https://mx1.mail.test.local:8080/secret.html | - | https://mx2.mail.test.local:8080/secret.html | + | url | + | https://public.test.local:8080/secret.html | + | https://dev.test.local:8080/groups/admin/secret.html | + | https://dev.test.local:8080/groups/dev/secret.html | + | https://dev.test.local:8080/users/john/secret.html | + | https://dev.test.local:8080/users/harry/secret.html | + | https://dev.test.local:8080/users/bob/secret.html | + | https://admin.test.local:8080/secret.html | + | https://mx1.mail.test.local:8080/secret.html | + And I have no access to: + | url | + | https://mx2.mail.test.local:8080/secret.html | @need-registered-user-bob Scenario: User bob has restricted access @@ -22,15 +27,18 @@ Feature: User has access restricted access to domains And I use "REGISTERED" as TOTP token handle And I click on "TOTP" Then I have access to: - | url | - | https://public.test.local:8080/secret.html | - | https://secret.test.local:8080/secret.html | - | https://secret2.test.local:8080/secret.html | - | https://mx1.mail.test.local:8080/secret.html | - | https://mx2.mail.test.local:8080/secret.html | + | url | + | https://public.test.local:8080/secret.html | + | https://dev.test.local:8080/groups/dev/secret.html | + | https://dev.test.local:8080/users/bob/secret.html | + | https://mx1.mail.test.local:8080/secret.html | + | https://mx2.mail.test.local:8080/secret.html | And I have no access to: - | url | - | https://secret1.test.local:8080/secret.html | + | url | + | https://dev.test.local:8080/groups/admin/secret.html | + | https://admin.test.local:8080/secret.html | + | https://dev.test.local:8080/users/john/secret.html | + | https://dev.test.local:8080/users/harry/secret.html | @need-registered-user-harry Scenario: User harry has restricted access @@ -39,12 +47,15 @@ Feature: User has access restricted access to domains And I use "REGISTERED" as TOTP token handle And I click on "TOTP" Then I have access to: - | url | - | https://public.test.local:8080/secret.html | - | https://secret1.test.local:8080/secret.html | + | url | + | https://public.test.local:8080/secret.html | + | https://dev.test.local:8080/users/harry/secret.html | And I have no access to: - | url | - | https://secret.test.local:8080/secret.html | - | https://secret2.test.local:8080/secret.html | - | https://mx1.mail.test.local:8080/secret.html | - | https://mx2.mail.test.local:8080/secret.html | \ No newline at end of file + | url | + | https://dev.test.local:8080/groups/dev/secret.html | + | https://dev.test.local:8080/users/bob/secret.html | + | https://dev.test.local:8080/groups/admin/secret.html | + | https://admin.test.local:8080/secret.html | + | https://dev.test.local:8080/users/john/secret.html | + | https://mx1.mail.test.local:8080/secret.html | + | https://mx2.mail.test.local:8080/secret.html | diff --git a/test/features/authentication.feature b/test/features/authentication.feature index e1ed84e2..f48bd8ad 100644 --- a/test/features/authentication.feature +++ b/test/features/authentication.feature @@ -18,19 +18,19 @@ Feature: User validate first factor Given I visit "https://auth.test.local:8080/" And I login with user "john" and password "password" And I register a TOTP secret called "Sec0" - When I visit "https://secret.test.local:8080/secret.html" and get redirected "https://auth.test.local:8080/?redirect=https%3A%2F%2Fsecret.test.local%3A8080%2Fsecret.html" + When I visit "https://admin.test.local:8080/secret.html" and get redirected "https://auth.test.local:8080/?redirect=https%3A%2F%2Fadmin.test.local%3A8080%2Fsecret.html" And I login with user "john" and password "password" And I use "Sec0" as TOTP token handle And I click on "TOTP" - Then I'm redirected to "https://secret.test.local:8080/secret.html" + Then I'm redirected to "https://admin.test.local:8080/secret.html" Scenario: User fails TOTP second factor - When I visit "https://secret.test.local:8080/secret.html" and get redirected "https://auth.test.local:8080/?redirect=https%3A%2F%2Fsecret.test.local%3A8080%2Fsecret.html" + When I visit "https://admin.test.local:8080/secret.html" and get redirected "https://auth.test.local:8080/?redirect=https%3A%2F%2Fadmin.test.local%3A8080%2Fsecret.html" And I login with user "john" and password "password" And I use "BADTOKEN" as TOTP token And I click on "TOTP" Then I get a notification of type "error" with message "Problem with TOTP validation." Scenario: Logout redirects user to redirect URL given in parameter - When I visit "https://auth.test.local:8080/logout?redirect=https://www.google.fr" - Then I'm redirected to "https://www.google.fr" + When I visit "https://auth.test.local:8080/logout?redirect=https://home.test.local:8080/" + Then I'm redirected to "https://home.test.local:8080/" diff --git a/test/features/redirection.feature b/test/features/redirection.feature index 9884a139..ab508f94 100644 --- a/test/features/redirection.feature +++ b/test/features/redirection.feature @@ -1,8 +1,7 @@ Feature: User is correctly redirected Scenario: User is redirected to authelia when he is not authenticated - Given I'm on https://home.test.local:8080 - When I click on the link to secret.test.local + When I visit "https://public.test.local:8080" Then I'm redirected to "https://auth.test.local:8080/" @need-registered-user-john @@ -15,9 +14,9 @@ Feature: User is correctly redirected And I click on "TOTP" Then I'm redirected to "https://public.test.local:8080/secret.html" - Scenario: User Harry does not have access to https://secret.test.local:8080/secret.html and thus he must get an error 403 + Scenario: User Harry does not have access to admin domain and thus he must get an error 403 When I register TOTP and login with user "harry" and password "password" - And I visit "https://secret.test.local:8080/secret.html" + And I visit "https://admin.test.local:8080/secret.html" Then I get an error 403 diff --git a/test/features/resilience.feature b/test/features/resilience.feature index 755cca02..ec4b4603 100644 --- a/test/features/resilience.feature +++ b/test/features/resilience.feature @@ -5,15 +5,15 @@ Feature: Authelia keeps user sessions despite the application restart When the application restarts Then I have access to: | url | - | https://secret.test.local:8080/secret.html | + | https://admin.test.local:8080/secret.html | @need-registered-user-john Scenario: Secrets are stored even when Authelia restarts When the application restarts - And I visit "https://secret.test.local:8080/secret.html" and get redirected "https://auth.test.local:8080/?redirect=https%3A%2F%2Fsecret.test.local%3A8080%2Fsecret.html" + And I visit "https://admin.test.local:8080/secret.html" and get redirected "https://auth.test.local:8080/?redirect=https%3A%2F%2Fadmin.test.local%3A8080%2Fsecret.html" And I login with user "john" and password "password" And I use "REGISTERED" as TOTP token handle And I click on "TOTP" Then I have access to: | url | - | https://secret.test.local:8080/secret.html | \ No newline at end of file + | https://admin.test.local:8080/secret.html | \ No newline at end of file diff --git a/test/features/step_definitions/authentication.ts b/test/features/step_definitions/authentication.ts index a6285d4c..0f5ad905 100644 --- a/test/features/step_definitions/authentication.ts +++ b/test/features/step_definitions/authentication.ts @@ -81,7 +81,7 @@ and I use TOTP token handle {stringInDoubleQuotes}", } Then("I have access to:", function (dataTable: Cucumber.TableDefinition) { - const promises = []; + const promises: any = []; for (let i = 0; i < dataTable.rows().length; i++) { const url = (dataTable.hashes() as any)[i].url; promises.push(hasAccessToSecret(url, this)); diff --git a/test/features/support/world.ts b/test/features/support/world.ts index 8828ad9b..0335b78e 100644 --- a/test/features/support/world.ts +++ b/test/features/support/world.ts @@ -47,6 +47,9 @@ function CustomWorld() { .findElement(seleniumWebdriver.By.tagName("button")) .findElement(seleniumWebdriver.By.xpath("//button[contains(.,'" + buttonText + "')]")) .click(); + }) + .then(function () { + return that.driver.sleep(500); }); }; diff --git a/test/unit/server/SessionConfigurationBuilder.test.ts b/test/unit/server/SessionConfigurationBuilder.test.ts index 3e24452a..69882b23 100644 --- a/test/unit/server/SessionConfigurationBuilder.test.ts +++ b/test/unit/server/SessionConfigurationBuilder.test.ts @@ -11,7 +11,8 @@ describe("test session configuration builder", function () { it("should return session options without redis options", function () { const configuration: AppConfiguration = { access_control: { - default: [], + default_policy: "deny", + any: [], users: {}, groups: {} }, @@ -81,7 +82,8 @@ describe("test session configuration builder", function () { it("should return session options with redis options", function () { const configuration: AppConfiguration = { access_control: { - default: [], + default_policy: "deny", + any: [], users: {}, groups: {} }, diff --git a/test/unit/server/access_control/AccessController.test.ts b/test/unit/server/access_control/AccessController.test.ts index 440cde3c..d1586039 100644 --- a/test/unit/server/access_control/AccessController.test.ts +++ b/test/unit/server/access_control/AccessController.test.ts @@ -1,53 +1,353 @@ -import assert = require("assert"); +import Assert = require("assert"); import winston = require("winston"); -import { AccessController } from "../../../../src/server/lib/access_control/AccessController"; -import { ACLConfiguration } from "../../../../src/server/lib/configuration/Configuration"; +import { AccessController } from "../../../../src/server/lib/access_control/AccessController"; +import { ACLConfiguration, ACLRule } from "../../../../src/server/lib/configuration/Configuration"; describe("test access control manager", function () { - let accessController: AccessController; - let configuration: ACLConfiguration; + let accessController: AccessController; + let configuration: ACLConfiguration; + beforeEach(function () { + configuration = { + default_policy: "deny", + any: [], + users: {}, + groups: {} + }; + accessController = new AccessController(configuration, winston); + }); + + describe("check access control with default policy to deny", function () { beforeEach(function () { - configuration = { - default: [], - users: {}, - groups: {} - }; - accessController = new AccessController(configuration, winston); + configuration.default_policy = "deny"; }); - describe("check access control matching", function () { - beforeEach(function () { - configuration.default = ["home.example.com", "*.public.example.com"]; - configuration.users = { - user1: ["user1.example.com", "user1.mail.example.com"] - }; - configuration.groups = { - group1: ["secret2.example.com"], - group2: ["secret.example.com", "secret1.example.com"] - }; - }); - - it("should allow access to secret.example.com", function () { - assert(accessController.isDomainAllowedForUser("secret.example.com", "user", ["group1", "group2"])); - }); - - it("should deny access to secret3.example.com", function () { - assert(!accessController.isDomainAllowedForUser("secret3.example.com", "user", ["group1", "group2"])); - }); - - it("should allow access to home.example.com", function () { - assert(accessController.isDomainAllowedForUser("home.example.com", "user", ["group1", "group2"])); - }); - - it("should allow access to user1.example.com", function () { - assert(accessController.isDomainAllowedForUser("user1.example.com", "user1", ["group1", "group2"])); - }); - - it("should allow access *.public.example.com", function () { - assert(accessController.isDomainAllowedForUser("user.public.example.com", "nouser", [])); - assert(accessController.isDomainAllowedForUser("test.public.example.com", "nouser", [])); - }); + it("should deny access when no rule is provided", function () { + Assert(!accessController.isAccessAllowed("home.example.com", "/", "user1", ["group1"])); }); + + it("should control access when multiple domain matcher is provided", function () { + configuration.users["user1"] = [{ + domain: "*.mail.example.com", + policy: "allow", + resources: [".*"] + }]; + Assert(!accessController.isAccessAllowed("home.example.com", "/", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("mx1.mail.example.com", "/", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("mx1.server.mail.example.com", "/", "user1", ["group1"])); + Assert(!accessController.isAccessAllowed("mail.example.com", "/", "user1", ["group1"])); + }); + + it("should allow access to all resources when resources is not provided", function () { + configuration.users["user1"] = [{ + domain: "*.mail.example.com", + policy: "allow" + }]; + Assert(!accessController.isAccessAllowed("home.example.com", "/", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("mx1.mail.example.com", "/", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("mx1.server.mail.example.com", "/", "user1", ["group1"])); + Assert(!accessController.isAccessAllowed("mail.example.com", "/", "user1", ["group1"])); + }); + + describe("check user rules", function () { + it("should allow access when user has a matching allowing rule", function () { + configuration.users["user1"] = [{ + domain: "home.example.com", + policy: "allow", + resources: [".*"] + }]; + Assert(accessController.isAccessAllowed("home.example.com", "/", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("home.example.com", "/another/resource", "user1", ["group1"])); + Assert(!accessController.isAccessAllowed("another.home.example.com", "/", "user1", ["group1"])); + }); + + it("should deny to other users", function () { + configuration.users["user1"] = [{ + domain: "home.example.com", + policy: "allow", + resources: [".*"] + }]; + Assert(!accessController.isAccessAllowed("home.example.com", "/", "user2", ["group1"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/another/resource", "user2", ["group1"])); + Assert(!accessController.isAccessAllowed("another.home.example.com", "/", "user2", ["group1"])); + }); + + it("should allow user access only to specific resources", function () { + configuration.users["user1"] = [{ + domain: "home.example.com", + policy: "allow", + resources: ["/private/.*", "^/begin", "/end$"] + }]; + Assert(!accessController.isAccessAllowed("home.example.com", "/", "user1", ["group1"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/private", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("home.example.com", "/private/class", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("home.example.com", "/middle/private/class", "user1", ["group1"])); + + Assert(accessController.isAccessAllowed("home.example.com", "/begin", "user1", ["group1"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/not/begin", "user1", ["group1"])); + + Assert(accessController.isAccessAllowed("home.example.com", "/abc/end", "user1", ["group1"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/abc/end/x", "user1", ["group1"])); + }); + + it("should allow access to multiple domains", function () { + configuration.users["user1"] = [{ + domain: "home.example.com", + policy: "allow", + resources: [".*"] + }, { + domain: "home1.example.com", + policy: "allow", + resources: [".*"] + }, { + domain: "home2.example.com", + policy: "deny", + resources: [".*"] + }]; + Assert(accessController.isAccessAllowed("home.example.com", "/", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("home1.example.com", "/", "user1", ["group1"])); + Assert(!accessController.isAccessAllowed("home2.example.com", "/", "user1", ["group1"])); + Assert(!accessController.isAccessAllowed("home3.example.com", "/", "user1", ["group1"])); + }); + + it("should always apply latest rule", function () { + configuration.users["user1"] = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/my/.*"] + }, { + domain: "home.example.com", + policy: "deny", + resources: ["^/my/private/.*"] + }, { + domain: "home.example.com", + policy: "allow", + resources: ["/my/private/resource"] + }]; + + Assert(accessController.isAccessAllowed("home.example.com", "/my/poney", "user1", ["group1"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/my/private/duck", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("home.example.com", "/my/private/resource", "user1", ["group1"])); + }); + }); + + describe("check group rules", function () { + it("should allow access when user is in group having a matching allowing rule", function () { + configuration.groups["group1"] = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/$"] + }]; + configuration.groups["group2"] = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/test$"] + }, { + domain: "home.example.com", + policy: "deny", + resources: ["^/private$"] + }]; + Assert(accessController.isAccessAllowed("home.example.com", "/", "user1", + ["group1", "group2", "group3"])); + Assert(accessController.isAccessAllowed("home.example.com", "/test", "user1", + ["group1", "group2", "group3"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/private", "user1", + ["group1", "group2", "group3"])); + Assert(!accessController.isAccessAllowed("another.home.example.com", "/", "user1", + ["group1", "group2", "group3"])); + }); + }); + }); + + describe("check all rules", function () { + it("should control access when all rules are defined", function () { + configuration.any = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/public$"] + }, { + domain: "home.example.com", + policy: "deny", + resources: ["^/private$"] + }]; + Assert(accessController.isAccessAllowed("home.example.com", "/public", "user1", + ["group1", "group2", "group3"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/private", "user1", + ["group1", "group2", "group3"])); + Assert(accessController.isAccessAllowed("home.example.com", "/public", "user4", + ["group5"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/private", "user4", + ["group5"])); + }); + }); + + describe("check access control with default policy to allow", function () { + beforeEach(function () { + configuration.default_policy = "allow"; + }); + + it("should allow access to anything when no rule is provided", function () { + Assert(accessController.isAccessAllowed("home.example.com", "/", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("home.example.com", "/test", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("home.example.com", "/dev", "user1", ["group1"])); + }); + + it("should deny access to one resource when defined", function () { + configuration.users["user1"] = [{ + domain: "home.example.com", + policy: "deny", + resources: ["/test"] + }]; + Assert(accessController.isAccessAllowed("home.example.com", "/", "user1", ["group1"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/test", "user1", ["group1"])); + Assert(accessController.isAccessAllowed("home.example.com", "/dev", "user1", ["group1"])); + }); + }); + + describe("check access control with complete use case", function () { + beforeEach(function () { + configuration.default_policy = "deny"; + }); + + it("should control access of multiple user (real use case)", function () { + // Let say we have three users: admin, john, harry. + // admin is in groups ["admins"] + // john is in groups ["dev", "admin-private"] + // harry is in groups ["dev"] + configuration.any = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/public$", "^/$"] + }]; + configuration.groups["dev"] = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/dev/?.*$"] + }]; + configuration.groups["admins"] = [{ + domain: "home.example.com", + policy: "allow", + resources: [".*"] + }]; + configuration.groups["admin-private"] = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/private/?.*"] + }]; + configuration.users["john"] = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/private/john$"] + }]; + configuration.users["harry"] = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/private/harry"] + }, { + domain: "home.example.com", + policy: "deny", + resources: ["^/dev/b.*$"] + }]; + + Assert(accessController.isAccessAllowed("home.example.com", "/", "admin", ["admins"])); + Assert(accessController.isAccessAllowed("home.example.com", "/public", "admin", ["admins"])); + Assert(accessController.isAccessAllowed("home.example.com", "/dev", "admin", ["admins"])); + Assert(accessController.isAccessAllowed("home.example.com", "/dev/bob", "admin", ["admins"])); + Assert(accessController.isAccessAllowed("home.example.com", "/admin", "admin", ["admins"])); + Assert(accessController.isAccessAllowed("home.example.com", "/private/josh", "admin", ["admins"])); + Assert(accessController.isAccessAllowed("home.example.com", "/private/john", "admin", ["admins"])); + Assert(accessController.isAccessAllowed("home.example.com", "/private/harry", "admin", ["admins"])); + + Assert(accessController.isAccessAllowed("home.example.com", "/", "john", ["dev", "admin-private"])); + Assert(accessController.isAccessAllowed("home.example.com", "/public", "john", ["dev", "admin-private"])); + Assert(accessController.isAccessAllowed("home.example.com", "/dev", "john", ["dev", "admin-private"])); + Assert(accessController.isAccessAllowed("home.example.com", "/dev/bob", "john", ["dev", "admin-private"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/admin", "john", ["dev", "admin-private"])); + Assert(accessController.isAccessAllowed("home.example.com", "/private/josh", "john", ["dev", "admin-private"])); + Assert(accessController.isAccessAllowed("home.example.com", "/private/john", "john", ["dev", "admin-private"])); + Assert(accessController.isAccessAllowed("home.example.com", "/private/harry", "john", ["dev", "admin-private"])); + + Assert(accessController.isAccessAllowed("home.example.com", "/", "harry", ["dev"])); + Assert(accessController.isAccessAllowed("home.example.com", "/public", "harry", ["dev"])); + Assert(accessController.isAccessAllowed("home.example.com", "/dev", "harry", ["dev"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/dev/bob", "harry", ["dev"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/admin", "harry", ["dev"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/private/josh", "harry", ["dev"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/private/john", "harry", ["dev"])); + Assert(accessController.isAccessAllowed("home.example.com", "/private/harry", "harry", ["dev"])); + }); + + it("should control access when allowed at group level and denied at user level", function () { + configuration.groups["dev"] = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/dev/?.*$"] + }]; + configuration.users["john"] = [{ + domain: "home.example.com", + policy: "deny", + resources: ["^/dev/bob$"] + }]; + + Assert(accessController.isAccessAllowed("home.example.com", "/dev/john", "john", ["dev"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/dev/bob", "john", ["dev"])); + }); + + it("should control access when allowed at all level and denied at user level", function () { + configuration.any = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/dev/?.*$"] + }]; + configuration.users["john"] = [{ + domain: "home.example.com", + policy: "deny", + resources: ["^/dev/bob$"] + }]; + + Assert(accessController.isAccessAllowed("home.example.com", "/dev/john", "john", ["dev"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/dev/bob", "john", ["dev"])); + }); + + it("should control access when allowed at all level and denied at group level", function () { + configuration.any = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/dev/?.*$"] + }]; + configuration.groups["dev"] = [{ + domain: "home.example.com", + policy: "deny", + resources: ["^/dev/bob$"] + }]; + + Assert(accessController.isAccessAllowed("home.example.com", "/dev/john", "john", ["dev"])); + Assert(!accessController.isAccessAllowed("home.example.com", "/dev/bob", "john", ["dev"])); + }); + + it("should respect rules precedence", function () { + // the priority from least to most is 'default_policy', 'all', 'group', 'user' + // and the first rules in each category as a lower priority than the latest. + // You can think of it that way: they override themselves inside each category. + configuration.any = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/dev/?.*$"] + }]; + configuration.groups["dev"] = [{ + domain: "home.example.com", + policy: "deny", + resources: ["^/dev/bob$"] + }]; + configuration.users["john"] = [{ + domain: "home.example.com", + policy: "allow", + resources: ["^/dev/?.*$"] + }]; + + Assert(accessController.isAccessAllowed("home.example.com", "/dev/john", "john", ["dev"])); + Assert(accessController.isAccessAllowed("home.example.com", "/dev/bob", "john", ["dev"])); + }); + }); }); diff --git a/test/unit/server/access_control/PatternBuilder.test.ts b/test/unit/server/access_control/PatternBuilder.test.ts deleted file mode 100644 index 9cfd08a5..00000000 --- a/test/unit/server/access_control/PatternBuilder.test.ts +++ /dev/null @@ -1,120 +0,0 @@ - -import assert = require("assert"); -import winston = require("winston"); - -import PatternBuilder from "../../../../src/server/lib/access_control/PatternBuilder"; -import { ACLConfiguration } from "../../../../src/server/lib/configuration/Configuration"; - -describe("test access control manager", function () { - describe("test access control pattern builder when no configuration is provided", () => { - it("should allow access to the user", () => { - const patternBuilder = new PatternBuilder(undefined, winston); - - const allowed_domains = patternBuilder.getAllowedDomains("user", ["group1"]); - assert.deepEqual(allowed_domains, ["*"]); - }); - }); - - describe("test access control pattern builder", function () { - let patternBuilder: PatternBuilder; - let configuration: ACLConfiguration; - - - beforeEach(() => { - configuration = { - default: [], - users: {}, - groups: {} - }; - patternBuilder = new PatternBuilder(configuration, winston); - }); - - it("should deny all if nothing is defined in the config", function () { - const allowed_domains = patternBuilder.getAllowedDomains("user", ["group1", "group2"]); - assert.deepEqual(allowed_domains, []); - }); - - it("should allow domain test.example.com to all users if defined in" + - " default policy", function () { - configuration.default = ["test.example.com"]; - const allowed_domains = patternBuilder.getAllowedDomains("user", ["group1", "group2"]); - assert.deepEqual(allowed_domains, ["test.example.com"]); - }); - - it("should allow domain test.example.com to all users in group mygroup", function () { - const allowed_domains0 = patternBuilder.getAllowedDomains("user", ["group1", "group1"]); - assert.deepEqual(allowed_domains0, []); - - configuration.groups = { - mygroup: ["test.example.com"] - }; - - const allowed_domains1 = patternBuilder.getAllowedDomains("user", ["group1", "group2"]); - assert.deepEqual(allowed_domains1, []); - - const allowed_domains2 = patternBuilder.getAllowedDomains("user", ["group1", "mygroup"]); - assert.deepEqual(allowed_domains2, ["test.example.com"]); - }); - - it("should allow domain test.example.com based on per user config", function () { - const allowed_domains0 = patternBuilder.getAllowedDomains("user", ["group1"]); - assert.deepEqual(allowed_domains0, []); - - configuration.users = { - user1: ["test.example.com"] - }; - - const allowed_domains1 = patternBuilder.getAllowedDomains("user", ["group1", "mygroup"]); - assert.deepEqual(allowed_domains1, []); - - const allowed_domains2 = patternBuilder.getAllowedDomains("user1", ["group1", "mygroup"]); - assert.deepEqual(allowed_domains2, ["test.example.com"]); - }); - - it("should allow domains from user and groups", function () { - configuration.groups = { - group2: ["secret.example.com", "secret1.example.com"] - }; - configuration.users = { - user: ["test.example.com"] - }; - - const allowed_domains0 = patternBuilder.getAllowedDomains("user", ["group1", "group2"]); - assert.deepEqual(allowed_domains0, [ - "secret.example.com", - "secret1.example.com", - "test.example.com", - ]); - }); - - it("should allow domains from several groups", function () { - configuration.groups = { - group1: ["secret2.example.com"], - group2: ["secret.example.com", "secret1.example.com"] - }; - - const allowed_domains0 = patternBuilder.getAllowedDomains("user", ["group1", "group2"]); - assert.deepEqual(allowed_domains0, [ - "secret2.example.com", - "secret.example.com", - "secret1.example.com", - ]); - }); - - it("should allow domains from several groups and default policy", function () { - configuration.default = ["home.example.com"]; - configuration.groups = { - group1: ["secret2.example.com"], - group2: ["secret.example.com", "secret1.example.com"] - }; - - const allowed_domains0 = patternBuilder.getAllowedDomains("user", ["group1", "group2"]); - assert.deepEqual(allowed_domains0, [ - "home.example.com", - "secret2.example.com", - "secret.example.com", - "secret1.example.com", - ]); - }); - }); -}); diff --git a/test/unit/server/configuration/ConfigurationAdapter.test.ts b/test/unit/server/configuration/ConfigurationAdapter.test.ts index 938654dc..d7886e8c 100644 --- a/test/unit/server/configuration/ConfigurationAdapter.test.ts +++ b/test/unit/server/configuration/ConfigurationAdapter.test.ts @@ -1,5 +1,8 @@ import * as Assert from "assert"; -import { UserConfiguration, LdapConfiguration } from "../../../../src/server/lib/configuration/Configuration"; +import { + UserConfiguration, + LdapConfiguration, ACLConfiguration +} from "../../../../src/server/lib/configuration/Configuration"; import { ConfigurationAdapter } from "../../../../src/server/lib/configuration/ConfigurationAdapter"; describe("test config adapter", function () { @@ -94,15 +97,17 @@ describe("test config adapter", function () { it("should get the access_control config", function () { const yaml_config = build_yaml_config(); yaml_config.access_control = { - default: [], + default_policy: "deny", + any: [], users: {}, groups: {} }; const config = ConfigurationAdapter.adapt(yaml_config); Assert.deepEqual(config.access_control, { - default: [], + default_policy: "deny", + any: [], users: {}, groups: {} - }); + } as ACLConfiguration); }); }); diff --git a/test/unit/server/mocks/AccessController.ts b/test/unit/server/mocks/AccessController.ts deleted file mode 100644 index ce46c0b8..00000000 --- a/test/unit/server/mocks/AccessController.ts +++ /dev/null @@ -1,12 +0,0 @@ - -import sinon = require("sinon"); - -export interface AccessControllerMock { - isDomainAllowedForUser: sinon.SinonStub; -} - -export function AccessControllerMock() { - return { - isDomainAllowedForUser: sinon.stub() - }; -} diff --git a/test/unit/server/mocks/AccessControllerStub.ts b/test/unit/server/mocks/AccessControllerStub.ts new file mode 100644 index 00000000..6d402e73 --- /dev/null +++ b/test/unit/server/mocks/AccessControllerStub.ts @@ -0,0 +1,15 @@ + +import Sinon = require("sinon"); +import { IAccessController } from "../../../../src/server/lib/access_control/IAccessController"; + +export class AccessControllerStub implements IAccessController { + isAccessAllowedMock: Sinon.SinonStub; + + constructor() { + this.isAccessAllowedMock = Sinon.stub(); + } + + isAccessAllowed(domain: string, resource: string, user: string, groups: string[]): boolean { + return this.isAccessAllowedMock(domain, resource, user, groups); + } +} diff --git a/test/unit/server/routes/firstfactor/post.test.ts b/test/unit/server/routes/firstfactor/post.test.ts index 4dd5e043..486b3c52 100644 --- a/test/unit/server/routes/firstfactor/post.test.ts +++ b/test/unit/server/routes/firstfactor/post.test.ts @@ -10,7 +10,7 @@ import AuthenticationSession = require("../../../../../src/server/lib/Authentica import Endpoints = require("../../../../../src/server/endpoints"); import AuthenticationRegulatorMock = require("../../mocks/AuthenticationRegulator"); -import AccessControllerMock = require("../../mocks/AccessController"); +import { AccessControllerStub } from "../../mocks/AccessControllerStub"; import ExpressMock = require("../../mocks/express"); import ServerVariablesMock = require("../../mocks/ServerVariablesMock"); import { ServerVariables } from "../../../../../src/server/lib/ServerVariablesHandler"; @@ -22,7 +22,7 @@ describe("test the first factor validation route", function () { let groups: string[]; let configuration; let regulator: AuthenticationRegulatorMock.AuthenticationRegulatorMock; - let accessController: AccessControllerMock.AccessControllerMock; + let accessController: AccessControllerStub; let serverVariables: ServerVariables; beforeEach(function () { @@ -36,8 +36,8 @@ describe("test the first factor validation route", function () { emails = ["test_ok@example.com"]; groups = ["group1", "group2" ]; - accessController = AccessControllerMock.AccessControllerMock(); - accessController.isDomainAllowedForUser.returns(true); + accessController = new AccessControllerStub(); + accessController.isAccessAllowedMock.returns(true); regulator = AuthenticationRegulatorMock.AuthenticationRegulatorMock(); regulator.regulate.returns(BluebirdPromise.resolve()); diff --git a/test/unit/server/routes/verify/get.test.ts b/test/unit/server/routes/verify/get.test.ts index 9de9fd11..875c23bc 100644 --- a/test/unit/server/routes/verify/get.test.ts +++ b/test/unit/server/routes/verify/get.test.ts @@ -10,17 +10,17 @@ import BluebirdPromise = require("bluebird"); import express = require("express"); import ExpressMock = require("../../mocks/express"); -import AccessControllerMock = require("../../mocks/AccessController"); +import { AccessControllerStub } from "../../mocks/AccessControllerStub"; import ServerVariablesMock = require("../../mocks/ServerVariablesMock"); describe("test authentication token verification", function () { let req: ExpressMock.RequestMock; let res: ExpressMock.ResponseMock; - let accessController: AccessControllerMock.AccessControllerMock; + let accessController: AccessControllerStub; beforeEach(function () { - accessController = AccessControllerMock.AccessControllerMock(); - accessController.isDomainAllowedForUser.returns(true); + accessController = new AccessControllerStub(); + accessController.isAccessAllowedMock.returns(true); req = ExpressMock.RequestMock(); res = ExpressMock.ResponseMock(); @@ -128,8 +128,8 @@ describe("test authentication token verification", function () { req.headers.host = "test.example.com"; - accessController.isDomainAllowedForUser.returns(false); - accessController.isDomainAllowedForUser.withArgs("test.example.com", "user", ["group1", "group2"]).returns(true); + accessController.isAccessAllowedMock.returns(false); + accessController.isAccessAllowedMock.withArgs("test.example.com", "user", ["group1", "group2"]).returns(true); return test_unauthorized_403({ first_factor: true,