Compare commits
186 Commits
version/20
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
e6b515e3f7 | |||
36eaecfdec | |||
3973efae19 | |||
d8492e0df5 | |||
b64da0dd28 | |||
c3ae3e02f3 | |||
7c6a96394b | |||
0fe43f8319 | |||
7e32723748 | |||
577aa7ba79 | |||
b752540800 | |||
64c8ca9b5d | |||
5552e0ffa7 | |||
e7b7bfddd6 | |||
28f970c795 | |||
d1dbdfa9fe | |||
c4f4e3eac7 | |||
f21ebf5488 | |||
5615613ed1 | |||
669329e49c | |||
0587ab26e8 | |||
3c9cc9d421 | |||
1972464a20 | |||
3041a30193 | |||
1e28a1e311 | |||
5a1b912b76 | |||
464c27ef17 | |||
a745022f06 | |||
0b34f70205 | |||
a4b051fcc1 | |||
5ff3e9b418 | |||
8ae7403abc | |||
f6e1bfdfc8 | |||
aca3a5c458 | |||
d16c24fd53 | |||
6a8be0dc71 | |||
81b9b37e5e | |||
22b01962fb | |||
86cc99be35 | |||
416f917c4a | |||
f77bece790 | |||
a8dd846437 | |||
4c50769040 | |||
34189fcc06 | |||
fb5c8f3d7f | |||
049a55a761 | |||
4cd53f3d11 | |||
0d0dcf8de0 | |||
8cd1223081 | |||
1b4654bb1d | |||
0a3fade1fd | |||
ff64814f40 | |||
cbeb6e58ac | |||
285a9b8b1d | |||
66bfa6879d | |||
c05240afbf | |||
7370dd5f3f | |||
477c8b099e | |||
2c761da883 | |||
75070232b1 | |||
690b35e1a3 | |||
bd67f2362f | |||
896e5adce2 | |||
7f25b6311d | |||
253f345fc4 | |||
a3abbcec6a | |||
70e000d327 | |||
a7467e6740 | |||
b3da94bbb8 | |||
e62f5a75e4 | |||
39ad9d7c9d | |||
20d09c14b2 | |||
3a4d514bae | |||
4932846e14 | |||
bb62aa7c7f | |||
907b837301 | |||
b60a3d45dc | |||
3f5585ca84 | |||
ba9a4efc9b | |||
902378af53 | |||
2352a7f4d6 | |||
d89266a9d2 | |||
d678d33756 | |||
49d0ccd9c7 | |||
ea082ed9ef | |||
d62fc9766c | |||
983747b13b | |||
de4710ea71 | |||
d55b31dd82 | |||
d87871f806 | |||
148194e12b | |||
a2c587be43 | |||
673da2a96e | |||
a9a7b26264 | |||
83d2c442a5 | |||
4029e19b72 | |||
538a466090 | |||
322a343c81 | |||
6ddd6bfa72 | |||
36de302250 | |||
9eb13c50e9 | |||
cffc6a1b88 | |||
ba437beacc | |||
da32b05eba | |||
45b7e7565d | |||
a0b63f50bf | |||
dc5d571c99 | |||
05161db458 | |||
311ffa9f79 | |||
7cbe33d65d | |||
be9ca48de0 | |||
b3159a74e5 | |||
89fafff0af | |||
ae77c872a0 | |||
5f13563e03 | |||
e17c9040bb | |||
280ef3d265 | |||
a5bb583268 | |||
212ff11b6d | |||
1fa9d70945 | |||
eeeaa9317b | |||
09b932100f | |||
aa701c5725 | |||
6f98833150 | |||
30aa24ce6e | |||
a426a1a0b6 | |||
061c549a40 | |||
efa09d5e1d | |||
4fe0bd4b6c | |||
7c2decf5ec | |||
7f39399c32 | |||
7fd78a591d | |||
bdb84b7a8f | |||
84e9748340 | |||
7dfc621ae4 | |||
cd0a6f2d7c | |||
b7835a751b | |||
fd197ceee7 | |||
be5c8341d2 | |||
2036827f04 | |||
35665d248e | |||
bc30b41157 | |||
2af7fab42c | |||
4de205809b | |||
e8433472fd | |||
3896299312 | |||
5cfbb0993a | |||
a62e3557ac | |||
626936636a | |||
85ec713213 | |||
406bbdcfc9 | |||
02f87032cc | |||
b7a929d304 | |||
3c0cc27ea1 | |||
ec254d5927 | |||
92ba77e9e5 | |||
7ddb459030 | |||
076e89b600 | |||
ba5fa2a04f | |||
90fe1c2ce8 | |||
85f88e785f | |||
a7c4f81275 | |||
396fbc4a76 | |||
2dcd0128aa | |||
e5aa9e0774 | |||
53d78d561b | |||
93001d1329 | |||
40428f5a82 | |||
007838fcf2 | |||
5e03b27348 | |||
7c51afa36c | |||
38fd5c5614 | |||
7e3148fab5 | |||
948db46406 | |||
cccddd8c69 | |||
3dc9e247d5 | |||
2a0bd50e23 | |||
ff42663d3c | |||
ce49d7ea5b | |||
8429dd19b2 | |||
1554dc9feb | |||
1005f341e4 | |||
b98895ac2c | |||
6dc38b0132 | |||
e154e28611 | |||
690b7be1d8 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2021.6.4
|
||||
current_version = 2021.7.1
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
||||
@ -29,8 +29,6 @@ values =
|
||||
|
||||
[bumpversion:file:internal/constants/constants.go]
|
||||
|
||||
[bumpversion:file:outpost/pkg/version.go]
|
||||
|
||||
[bumpversion:file:web/src/constants.ts]
|
||||
|
||||
[bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md]
|
||||
|
@ -3,3 +3,6 @@ static
|
||||
htmlcov
|
||||
*.env.yml
|
||||
**/node_modules
|
||||
dist/**
|
||||
build/**
|
||||
build_docs/**
|
||||
|
10
.github/dependabot.yml
vendored
10
.github/dependabot.yml
vendored
@ -9,7 +9,7 @@ updates:
|
||||
assignees:
|
||||
- BeryJu
|
||||
- package-ecosystem: gomod
|
||||
directory: "/outpost"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
@ -48,11 +48,3 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- BeryJu
|
||||
- package-ecosystem: docker
|
||||
directory: "/outpost"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- BeryJu
|
||||
|
19
.github/pull_request_template.md
vendored
Normal file
19
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
<!--
|
||||
👋 Hello there! Welcome.
|
||||
|
||||
Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/master/CONTRIBUTING.md#how-can-i-contribute).
|
||||
-->
|
||||
|
||||
# Details
|
||||
* **Does this resolve an issue?**
|
||||
Resolves #
|
||||
|
||||
## Changes
|
||||
### New Features
|
||||
* Adds feature which does x, y, and z.
|
||||
|
||||
### Breaking Changes
|
||||
* Adds breaking change which causes \<issue\>.
|
||||
|
||||
## Additional
|
||||
Any further notes or comments you want to make.
|
28
.github/workflows/release.yml
vendored
28
.github/workflows/release.yml
vendored
@ -33,14 +33,14 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik:2021.6.4,
|
||||
beryju/authentik:2021.7.1,
|
||||
beryju/authentik:latest,
|
||||
ghcr.io/goauthentik/server:2021.6.4,
|
||||
ghcr.io/goauthentik/server:2021.7.1,
|
||||
ghcr.io/goauthentik/server:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
- name: Building Docker Image (stable)
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.6.4', 'rc') }}
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.7.1', 'rc') }}
|
||||
run: |
|
||||
docker pull beryju/authentik:latest
|
||||
docker tag beryju/authentik:latest beryju/authentik:stable
|
||||
@ -75,14 +75,14 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik-proxy:2021.6.4,
|
||||
beryju/authentik-proxy:2021.7.1,
|
||||
beryju/authentik-proxy:latest,
|
||||
ghcr.io/goauthentik/proxy:2021.6.4,
|
||||
ghcr.io/goauthentik/proxy:2021.7.1,
|
||||
ghcr.io/goauthentik/proxy:latest
|
||||
file: outpost/proxy.Dockerfile
|
||||
file: proxy.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: Building Docker Image (stable)
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.6.4', 'rc') }}
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.7.1', 'rc') }}
|
||||
run: |
|
||||
docker pull beryju/authentik-proxy:latest
|
||||
docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable
|
||||
@ -117,14 +117,14 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik-ldap:2021.6.4,
|
||||
beryju/authentik-ldap:2021.7.1,
|
||||
beryju/authentik-ldap:latest,
|
||||
ghcr.io/goauthentik/ldap:2021.6.4,
|
||||
ghcr.io/goauthentik/ldap:2021.7.1,
|
||||
ghcr.io/goauthentik/ldap:latest
|
||||
file: outpost/ldap.Dockerfile
|
||||
file: ldap.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: Building Docker Image (stable)
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.6.4', 'rc') }}
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.7.1', 'rc') }}
|
||||
run: |
|
||||
docker pull beryju/authentik-ldap:latest
|
||||
docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable
|
||||
@ -157,7 +157,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v2.2.0
|
||||
uses: actions/setup-node@v2.3.0
|
||||
with:
|
||||
node-version: 12.x
|
||||
- name: Build web api client and web ui
|
||||
@ -176,7 +176,7 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
version: authentik@2021.6.4
|
||||
version: authentik@2021.7.1
|
||||
environment: beryjuorg-prod
|
||||
sourcemaps: './web/dist'
|
||||
finalize: false
|
||||
url_prefix: /static/dist
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -200,3 +200,4 @@ media/
|
||||
*mmdb
|
||||
|
||||
.idea/
|
||||
api/
|
||||
|
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
hello@beryju.org.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
180
CONTRIBUTING.md
Normal file
180
CONTRIBUTING.md
Normal file
@ -0,0 +1,180 @@
|
||||
# Contributing to authentik
|
||||
|
||||
:+1::tada: Thanks for taking the time to contribute! :tada::+1:
|
||||
|
||||
The following is a set of guidelines for contributing to authentik and its components, which are hosted in the [goauthentik Organization](https://github.com/goauthentik) on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request.
|
||||
|
||||
#### Table Of Contents
|
||||
|
||||
[Code of Conduct](#code-of-conduct)
|
||||
|
||||
[I don't want to read this whole thing, I just have a question!!!](#i-dont-want-to-read-this-whole-thing-i-just-have-a-question)
|
||||
|
||||
[What should I know before I get started?](#what-should-i-know-before-i-get-started)
|
||||
* [Atom and Packages](#atom-and-packages)
|
||||
* [Atom Design Decisions](#design-decisions)
|
||||
|
||||
[How Can I Contribute?](#how-can-i-contribute)
|
||||
* [Reporting Bugs](#reporting-bugs)
|
||||
* [Suggesting Enhancements](#suggesting-enhancements)
|
||||
* [Your First Code Contribution](#your-first-code-contribution)
|
||||
* [Pull Requests](#pull-requests)
|
||||
|
||||
[Styleguides](#styleguides)
|
||||
* [Git Commit Messages](#git-commit-messages)
|
||||
* [JavaScript Styleguide](#javascript-styleguide)
|
||||
* [CoffeeScript Styleguide](#coffeescript-styleguide)
|
||||
* [Specs Styleguide](#specs-styleguide)
|
||||
* [Documentation Styleguide](#documentation-styleguide)
|
||||
|
||||
[Additional Notes](#additional-notes)
|
||||
* [Issue and Pull Request Labels](#issue-and-pull-request-labels)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Basically, don't be a dickhead. This is an open-source non-profit project, that is made in the free time of Volunteers. If there's something you dislike or think can be done better, tell us! We'd love to hear any suggestions for improvement.
|
||||
|
||||
## I don't want to read this whole thing I just have a question!!!
|
||||
|
||||
Either [create a question on GitHub](https://github.com/goauthentik/authentik/issues/new?assignees=&labels=question&template=question.md&title=) or join [the Discord server](https://discord.gg/jg33eMhnj6)
|
||||
|
||||
## What should I know before I get started?
|
||||
|
||||
### The components
|
||||
|
||||
authentik consists of a few larger components:
|
||||
|
||||
- *authentik* the actual application server, is described below.
|
||||
- *outpost-proxy* is a Go application based on a forked version of oauth2_proxy, which does identity-aware reverse proxying.
|
||||
- *outpost-ldap* is a Go LDAP server that uses the *authentik* application server as its backend
|
||||
- *web* is the web frontend, both for administrating and using authentik. It is written in TypeScript using lit-html and the PatternFly CSS Library.
|
||||
- *website* is the Website/documentation, which uses docusaurus.
|
||||
|
||||
### authentik's structure
|
||||
|
||||
authentik is at it's very core a Django project. It consists of many individual django applications. These applications are intended to separate concerns, and they may share code between each other.
|
||||
|
||||
These are the current packages:
|
||||
<a id="authentik-packages"/>
|
||||
|
||||
```
|
||||
authentik
|
||||
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
|
||||
├── api - General API Configuration (Routes, Schema and general API utilities)
|
||||
├── core - Core authentik functionality, central routes, core Models
|
||||
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
|
||||
├── events - Event Log, middleware and signals to generate signals
|
||||
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
|
||||
├── lib - Generic library of functions, few dependencies on other packages.
|
||||
├── managed - Handle managed models and their state.
|
||||
├── outposts - Configure and deploy outposts on kubernetes and docker.
|
||||
├── policies - General PolicyEngine
|
||||
│ ├── dummy - A Dummy policy used for testing
|
||||
│ ├── event_matcher - Match events based on different criteria
|
||||
│ ├── expiry - Check when a user's password was last set
|
||||
│ ├── expression - Execute any arbitrary python code
|
||||
│ ├── hibp - Check a password against HaveIBeenPwned
|
||||
│ ├── password - Check a password against several rules
|
||||
│ └── reputation - Check the user's/client's reputation
|
||||
├── providers
|
||||
│ ├── ldap - Provide LDAP access to authentik users/groups using an outpost
|
||||
│ ├── oauth2 - OIDC-compliant OAuth2 provider
|
||||
│ ├── proxy - Provides an identity-aware proxy using an outpost
|
||||
│ └── saml - SAML2 Provider
|
||||
├── recovery - Generate keys to use in case you lock yourself out
|
||||
├── root - Root django application, contains global settings and routes
|
||||
├── sources
|
||||
│ ├── ldap - Sync LDAP users from OpenLDAP or Active Directory into authentik
|
||||
│ ├── oauth - OAuth1 and OAuth2 Source
|
||||
│ ├── plex - Plex source
|
||||
│ └── saml - SAML2 Source
|
||||
├── stages
|
||||
│ ├── authenticator_duo - Configure a DUO authenticator
|
||||
│ ├── authenticator_static - Configure TOTP backup keys
|
||||
│ ├── authenticator_totp - Configure a TOTP authenticator
|
||||
│ ├── authenticator_validate - Validate any authenticator
|
||||
│ ├── authenticator_webauthn - Configure a WebAuthn authenticator
|
||||
│ ├── captcha - Make the user pass a captcha
|
||||
│ ├── consent - Let the user decide if they want to consent to an action
|
||||
│ ├── deny - Static deny, can be used with policies
|
||||
│ ├── dummy - Dummy stage to test
|
||||
│ ├── email - Send the user an email and block execution until they click the link
|
||||
│ ├── identification - Identify a user with any combination of fields
|
||||
│ ├── invitation - Invitation system to limit flows to certain users
|
||||
│ ├── password - Password authentication
|
||||
│ ├── prompt - Arbitrary prompts
|
||||
│ ├── user_delete - Delete the currently pending user
|
||||
│ ├── user_login - Login the currently pending user
|
||||
│ ├── user_logout - Logout the currently pending user
|
||||
│ └── user_write - Write any currenetly pending data to the user.
|
||||
└── tenants - Soft tennancy, configure defaults and branding per domain
|
||||
```
|
||||
|
||||
This django project is running in gunicorn, which spawns multiple workers and threads. Gunicorn is run from a lightweight Go application which reverse-proxies it, handles static files and will eventually gain more functionality as more code is migrated to go.
|
||||
|
||||
There are also several background tasks which run in Celery, the root celery application is defined in `authentik.root.celery`.
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
This section guides you through submitting a bug report for authentik. Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find related reports.
|
||||
|
||||
Whenever authentik encounters an error, it will be logged as an Event with the type `system_exception`. This event type has a button to directly open a pre-filled GitHub issue form.
|
||||
|
||||
This form will have the full stack trace of the error that ocurred and shouldn't contain any sensitive data.
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
This section guides you through submitting an enhancement suggestion for authentik, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion and find related suggestions.
|
||||
|
||||
When you are creating an enhancement suggestion, please fill in [the template](https://github.com/goauthentik/authentik/issues/new?assignees=&labels=enhancement&template=feature_request.md&title=), including the steps that you imagine you would take if the feature you're requesting existed.
|
||||
|
||||
### Your First Code Contribution
|
||||
|
||||
#### Local development
|
||||
|
||||
authentik can be run locally, all though depending on which part you want to work on, different pre-requisites are required.
|
||||
|
||||
This is documented in the [developer docs](https://goauthentik.io/developer-docs/)
|
||||
|
||||
### Pull Requests
|
||||
|
||||
The process described here has several goals:
|
||||
|
||||
- Maintain authentik's quality
|
||||
- Fix problems that are important to users
|
||||
- Engage the community in working toward the best possible authentik
|
||||
- Enable a sustainable system for authentik's maintainers to review contributions
|
||||
|
||||
Please follow these steps to have your contribution considered by the maintainers:
|
||||
|
||||
1. Follow the [styleguides](#styleguides)
|
||||
2. After you submit your pull request, verify that all [status checks](https://help.github.com/articles/about-status-checks/) are passing <details><summary>What if the status checks are failing?</summary>If a status check is failing, and you believe that the failure is unrelated to your change, please leave a comment on the pull request explaining why you believe the failure is unrelated. A maintainer will re-run the status check for you. If we conclude that the failure was a false positive, then we will open an issue to track that problem with our status check suite.</details>
|
||||
3. Ensure your Code has tests. While it is not always possible to test every single case, the majority of the code should be tested.
|
||||
|
||||
While the prerequisites above must be satisfied prior to having your pull request reviewed, the reviewer(s) may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted.
|
||||
|
||||
## Styleguides
|
||||
|
||||
### Git Commit Messages
|
||||
|
||||
* Use the format of `<package>: <verb> <description>`
|
||||
- See [here](#authentik-packages) for `package`
|
||||
- Example: `providers/saml2: fix parsing of requests`
|
||||
* Reference issues and pull requests liberally after the first line
|
||||
|
||||
### Python Styleguide
|
||||
|
||||
All Python code is linted with [black](https://black.readthedocs.io/en/stable/), [PyLint](https://www.pylint.org/) and [isort](https://pycqa.github.io/isort/).
|
||||
|
||||
authentik runs on Python 3.9 at the time of writing this.
|
||||
|
||||
* Use native type-annotations wherever possible.
|
||||
* Add meaningful docstrings when possible.
|
||||
* Ensure any database migrations work properly from the last stable version (this is checked via CI)
|
||||
* If your code changes central functions, make sure nothing else is broken.
|
||||
|
||||
### Documentation Styleguide
|
||||
|
||||
* Use [MDX](https://mdxjs.com/) whenever appropriate.
|
50
Dockerfile
50
Dockerfile
@ -10,8 +10,16 @@ RUN pip install pipenv && \
|
||||
pipenv lock -r > requirements.txt && \
|
||||
pipenv lock -r --dev-only > requirements-dev.txt
|
||||
|
||||
# Stage 2: Build web API
|
||||
FROM openapitools/openapi-generator-cli as api-builder
|
||||
# Stage 2: Build website
|
||||
FROM node as website-builder
|
||||
|
||||
COPY ./website /static/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN cd /static && npm i && npm run build-docs-only
|
||||
|
||||
# Stage 3: Build web API
|
||||
FROM openapitools/openapi-generator-cli as web-api-builder
|
||||
|
||||
COPY ./schema.yml /local/schema.yml
|
||||
|
||||
@ -21,34 +29,52 @@ RUN docker-entrypoint.sh generate \
|
||||
-o /local/web/api \
|
||||
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
|
||||
|
||||
# Stage 3: Build webui
|
||||
FROM node as npm-builder
|
||||
# Stage 3: Generate API Client
|
||||
FROM openapitools/openapi-generator-cli as go-api-builder
|
||||
|
||||
COPY ./schema.yml /local/schema.yml
|
||||
|
||||
RUN docker-entrypoint.sh generate \
|
||||
--git-host goauthentik.io \
|
||||
--git-repo-id outpost \
|
||||
--git-user-id api \
|
||||
-i /local/schema.yml \
|
||||
-g go \
|
||||
-o /local/api \
|
||||
--additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true && \
|
||||
rm -f /local/api/go.mod /local/api/go.sum
|
||||
|
||||
# Stage 4: Build webui
|
||||
FROM node as web-builder
|
||||
|
||||
COPY ./web /static/
|
||||
COPY --from=api-builder /local/web/api /static/api
|
||||
COPY --from=web-api-builder /local/web/api /static/api
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN cd /static && npm i && npm run build
|
||||
|
||||
# Stage 4: Build go proxy
|
||||
FROM golang:1.16.5 AS builder
|
||||
# Stage 5: Build go proxy
|
||||
FROM golang:1.16.6 AS builder
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
COPY --from=npm-builder /static/robots.txt /work/web/robots.txt
|
||||
COPY --from=npm-builder /static/security.txt /work/web/security.txt
|
||||
COPY --from=npm-builder /static/dist/ /work/web/dist/
|
||||
COPY --from=npm-builder /static/authentik/ /work/web/authentik/
|
||||
COPY --from=web-builder /static/robots.txt /work/web/robots.txt
|
||||
COPY --from=web-builder /static/security.txt /work/web/security.txt
|
||||
COPY --from=web-builder /static/dist/ /work/web/dist/
|
||||
COPY --from=web-builder /static/authentik/ /work/web/authentik/
|
||||
COPY --from=website-builder /static/help/ /work/website/help/
|
||||
|
||||
COPY --from=go-api-builder /local/api api
|
||||
COPY ./cmd /work/cmd
|
||||
COPY ./web/static.go /work/web/static.go
|
||||
COPY ./website/static.go /work/website/static.go
|
||||
COPY ./internal /work/internal
|
||||
COPY ./go.mod /work/go.mod
|
||||
COPY ./go.sum /work/go.sum
|
||||
|
||||
RUN go build -o /work/authentik ./cmd/server/main.go
|
||||
|
||||
# Stage 5: Run
|
||||
# Stage 6: Run
|
||||
FROM python:3.9-slim-buster
|
||||
|
||||
WORKDIR /
|
||||
|
9
Makefile
9
Makefile
@ -32,7 +32,7 @@ gen-build:
|
||||
|
||||
gen-clean:
|
||||
rm -rf web/api/src/
|
||||
rm -rf outpost/api/
|
||||
rm -rf api/
|
||||
|
||||
gen-web:
|
||||
docker run \
|
||||
@ -55,11 +55,14 @@ gen-outpost:
|
||||
--git-user-id api \
|
||||
-i /local/schema.yml \
|
||||
-g go \
|
||||
-o /local/outpost/api \
|
||||
-o /local/api \
|
||||
--additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true
|
||||
rm -f outpost/api/go.mod outpost/api/go.sum
|
||||
rm -f api/go.mod api/go.sum
|
||||
|
||||
gen: gen-build gen-clean gen-web gen-outpost
|
||||
|
||||
migrate:
|
||||
python -m lifecycle.migrate
|
||||
|
||||
run:
|
||||
go run -v cmd/server/main.go
|
||||
|
1
Pipfile
1
Pipfile
@ -47,6 +47,7 @@ xmlsec = "*"
|
||||
duo-client = "*"
|
||||
ua-parser = "*"
|
||||
deepmerge = "*"
|
||||
colorama = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.9"
|
||||
|
377
Pipfile.lock
generated
377
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "f90d9fb4713eaf9c5ffe6a3858e64843670f79ab5007e7debf914c1f094c8d63"
|
||||
"sha256": "e4f2e57bd5c709809515ab2b95eb3f5fa337d4a9334f4110a24bf28c3f9d5f8f"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@ -122,19 +122,19 @@
|
||||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:3b35689c215c982fe9f7ef78d748aa9b0cd15c3b2eb04f9b460aaa63fe2fbd03",
|
||||
"sha256:b1cbeb92123799001b97f2ee1cdf470e21f1be08314ae28fc7ea357925186f1c"
|
||||
"sha256:a012570d3535ec6c4db97e60ef51c2f39f38246429e1455cecc26c633ed81c10",
|
||||
"sha256:c7f45b0417395d3020c98cdc10f942939883018210e29dbfe6fbfc0a74e503ec"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.17.105"
|
||||
"version": "==1.18.7"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:b0fda4edf8eb105453890700d49011ada576d0cc7326a0699dfabe9e872f552c",
|
||||
"sha256:b5ba72d22212b0355f339c2a98b3296b3b2202a48e6a2b1366e866bc65a64b67"
|
||||
"sha256:34c8b151a25616ed7791218f6d7780c3a97725fe3ceeaa28085b345a8513af6e",
|
||||
"sha256:dcf399d21170bb899e00d2a693bddcc79e61471fbfead8500a65578700a3190a"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==1.20.105"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==1.21.7"
|
||||
},
|
||||
"cachetools": {
|
||||
"hashes": [
|
||||
@ -146,22 +146,22 @@
|
||||
},
|
||||
"cbor2": {
|
||||
"hashes": [
|
||||
"sha256:059363ae716c60f6ba29aa61b3d9c57896189c351c4119095f0542aec169e4dc",
|
||||
"sha256:0b80a4a4fca830af3d3cf36b725c31f0a98106e9c2b02004ab73b0ec7f139446",
|
||||
"sha256:0d22b47fb24b384200277fcfb0582c3a3551c413ad51f3bd3ee334caaf79a483",
|
||||
"sha256:3c586a6e328ba5020802346f5e0304f81b982dcafeb51ee4109c9be9cccbc4a0",
|
||||
"sha256:4dd142764607b1a8b5e3e3b474d2b84099e9cbb323596a15ee8db0d78901d95f",
|
||||
"sha256:6f8a7911c2307ee8f8d4940bdcfb8bd21608f14203a83b651fcd7868bce377a5",
|
||||
"sha256:7ecc4e9c548282a5d296d4535244efa69c7f67cda959f28e14929cf1d6af8a97",
|
||||
"sha256:8bc9f5054650d05e6d3e90f6490dcd6ef6c01ad9c1568958a48dde2702824cb1",
|
||||
"sha256:98410520482796a547af2d5ffe11a8a2dc3b9f2124834fa7c12db8264935ed61",
|
||||
"sha256:a7926f7244b08c413f1a4fa71a81aa256771c75bdf1a4fd77308547a2d63dd48",
|
||||
"sha256:ae31d3b5966807fdff6c9e6f894b0aa10474295d9ff8467a8b978a569c8fec47",
|
||||
"sha256:ce6219986385778b1ab7f9b542f160bb4d3558f52975e914a27b774e47016fb7",
|
||||
"sha256:d562b2773e14ee1d65ea5b85351a83a64d4f3fd011bc2b4c70a6e813e78203ce"
|
||||
"sha256:0144ba1f44e4e36f7a8e8408eea72e1af6fc3ee42a704dacd4446307024e5231",
|
||||
"sha256:15102b45dd8b1879b8743159af4538cbf4b3240fe3ebc4e747f6842cd7775888",
|
||||
"sha256:40aa7c9dc9f69c38a2f9954e0adec266b04c55ed3188dc7a0213a92a2054220e",
|
||||
"sha256:4b62aa7a95960d1c382e858c2c4cb24375cde3cae137d11875bb9a4667731011",
|
||||
"sha256:6288b22cd3c0c842db2a4896473512fe83d24fa8ef4bc592d970635a2bb42e0e",
|
||||
"sha256:81676dc7802029299dc168a1240cf1058c1fe5303fbc64598fe14bdb1f8bc076",
|
||||
"sha256:8684c6ffbd35258cb9790ef2722559f585fb971288d6f55ee5efd9ba75dcc81b",
|
||||
"sha256:8ce511337cbac10ccb97093649d6597aacb648ce3198e6afe8b4931fd1cabc61",
|
||||
"sha256:986a8a9a4d3598008ece7241b746261118ef8d7c0efe7e6e9ce8b275f0421646",
|
||||
"sha256:a8bf432f6cb595f50aeb8fed2a4aa3b3f7caa7f135fb57e4378eaa39242feac9",
|
||||
"sha256:ba5e8065ca901ebec7ae390a183f3c13560454b6bd7dd81bf72c320e252b6461",
|
||||
"sha256:d66350d1323460e1e9dcb2f9caa591b60833623f909173b840a0891a245cad83",
|
||||
"sha256:e921d445575fbbe62ae68dc8ff3c6e05b341077fd24c6310c917b96fabe5e64a"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==5.4.0"
|
||||
"version": "==5.4.1"
|
||||
},
|
||||
"celery": {
|
||||
"hashes": [
|
||||
@ -180,65 +180,61 @@
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813",
|
||||
"sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373",
|
||||
"sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69",
|
||||
"sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f",
|
||||
"sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06",
|
||||
"sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05",
|
||||
"sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea",
|
||||
"sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee",
|
||||
"sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0",
|
||||
"sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396",
|
||||
"sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7",
|
||||
"sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f",
|
||||
"sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73",
|
||||
"sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315",
|
||||
"sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76",
|
||||
"sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1",
|
||||
"sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49",
|
||||
"sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed",
|
||||
"sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892",
|
||||
"sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482",
|
||||
"sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058",
|
||||
"sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5",
|
||||
"sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53",
|
||||
"sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045",
|
||||
"sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3",
|
||||
"sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55",
|
||||
"sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5",
|
||||
"sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e",
|
||||
"sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c",
|
||||
"sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369",
|
||||
"sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827",
|
||||
"sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053",
|
||||
"sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa",
|
||||
"sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4",
|
||||
"sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322",
|
||||
"sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132",
|
||||
"sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62",
|
||||
"sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa",
|
||||
"sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0",
|
||||
"sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396",
|
||||
"sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e",
|
||||
"sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991",
|
||||
"sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6",
|
||||
"sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc",
|
||||
"sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1",
|
||||
"sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406",
|
||||
"sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333",
|
||||
"sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d",
|
||||
"sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"
|
||||
"sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d",
|
||||
"sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771",
|
||||
"sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872",
|
||||
"sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c",
|
||||
"sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc",
|
||||
"sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762",
|
||||
"sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202",
|
||||
"sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5",
|
||||
"sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548",
|
||||
"sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a",
|
||||
"sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f",
|
||||
"sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20",
|
||||
"sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218",
|
||||
"sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c",
|
||||
"sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e",
|
||||
"sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56",
|
||||
"sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224",
|
||||
"sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a",
|
||||
"sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2",
|
||||
"sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a",
|
||||
"sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819",
|
||||
"sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346",
|
||||
"sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b",
|
||||
"sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e",
|
||||
"sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534",
|
||||
"sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb",
|
||||
"sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0",
|
||||
"sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156",
|
||||
"sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd",
|
||||
"sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87",
|
||||
"sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc",
|
||||
"sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195",
|
||||
"sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33",
|
||||
"sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f",
|
||||
"sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d",
|
||||
"sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd",
|
||||
"sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728",
|
||||
"sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7",
|
||||
"sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca",
|
||||
"sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99",
|
||||
"sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf",
|
||||
"sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e",
|
||||
"sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c",
|
||||
"sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5",
|
||||
"sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69"
|
||||
],
|
||||
"version": "==1.14.5"
|
||||
"version": "==1.14.6"
|
||||
},
|
||||
"channels": {
|
||||
"hashes": [
|
||||
"sha256:056b72e51080a517a0f33a0a30003e03833b551d75394d6636c885d4edb8188f",
|
||||
"sha256:3f15bdd2138bb4796e76ea588a0a344b12a7964ea9b2e456f992fddb988a4317"
|
||||
"sha256:0ff0422b4224d10efac76e451575517f155fe7c97d369b5973b116f22eeaf86c",
|
||||
"sha256:fdd9a94987a23d8d7ebd97498ed8b8cc83163f37e53fc6c85098aba7a3bb8b75"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.0.3"
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"channels-redis": {
|
||||
"hashes": [
|
||||
@ -256,6 +252,14 @@
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1",
|
||||
"sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12"
|
||||
],
|
||||
"markers": "python_version >= '3'",
|
||||
"version": "==2.0.3"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||
@ -284,6 +288,14 @@
|
||||
],
|
||||
"version": "==0.2.0"
|
||||
},
|
||||
"colorama": {
|
||||
"hashes": [
|
||||
"sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
|
||||
"sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.4.4"
|
||||
},
|
||||
"constantly": {
|
||||
"hashes": [
|
||||
"sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35",
|
||||
@ -434,11 +446,11 @@
|
||||
},
|
||||
"drf-spectacular": {
|
||||
"hashes": [
|
||||
"sha256:6ffbfde7d96a4a2febd19182cc405217e1e86a50280fc739402291c93d1a32b7",
|
||||
"sha256:77593024bb899f69227abedcf87def7851a11c9978f781aa4b385a10f67a38b7"
|
||||
"sha256:f080128c42183fcaed6b9e8e5afcd2e5cd68426b1f80bfc85938f25e62db7fe5",
|
||||
"sha256:fb19aa69fcfcd37b0c9dfb9989c0671e1bb47af332ca2171378c7f840263788c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.17.2"
|
||||
"version": "==0.17.3"
|
||||
},
|
||||
"duo-client": {
|
||||
"hashes": [
|
||||
@ -473,11 +485,11 @@
|
||||
},
|
||||
"google-auth": {
|
||||
"hashes": [
|
||||
"sha256:9266252e11393943410354cf14a77bcca24dd2ccd9c4e1aef23034fe0fbae630",
|
||||
"sha256:c7c215c74348ef24faef2f7b62f6d8e6b38824fe08b1e7b7b09a02d397eda7b3"
|
||||
"sha256:036dd68c1e8baa422b6b61619b8e02793da2e20f55e69514612de6c080468755",
|
||||
"sha256:7665c04f2df13cc938dc7d9066cddb1f8af62b038bc8b2306848c1b23121865f"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==1.32.1"
|
||||
"version": "==1.33.1"
|
||||
},
|
||||
"gunicorn": {
|
||||
"hashes": [
|
||||
@ -571,10 +583,10 @@
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
"sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
|
||||
"sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
|
||||
],
|
||||
"version": "==2.10"
|
||||
"version": "==3.2"
|
||||
},
|
||||
"incremental": {
|
||||
"hashes": [
|
||||
@ -624,14 +636,14 @@
|
||||
},
|
||||
"ldap3": {
|
||||
"hashes": [
|
||||
"sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91",
|
||||
"sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59",
|
||||
"sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c",
|
||||
"sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056",
|
||||
"sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57"
|
||||
"sha256:2bc966556fc4d4fa9f445a1c31dc484ee81d44a51ab0e2d0fd05b62cac75daa6",
|
||||
"sha256:5630d1383e09ba94839e253e013f1aa1a2cf7a547628ba1265cb7b9a844b5687",
|
||||
"sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70",
|
||||
"sha256:5ab7febc00689181375de40c396dcad4f2659cd260fc5e94c508b6d77c17e9d5",
|
||||
"sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.9"
|
||||
"version": "==2.9.1"
|
||||
},
|
||||
"lxml": {
|
||||
"hashes": [
|
||||
@ -975,18 +987,18 @@
|
||||
},
|
||||
"python-dateutil": {
|
||||
"hashes": [
|
||||
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
|
||||
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
|
||||
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
|
||||
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.8.1"
|
||||
"version": "==2.8.2"
|
||||
},
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:dd8fe852847f4fbfadabf6183ddd4c824a9651f02d51714fa075c95561959c7d",
|
||||
"sha256:effaac3c1e58d89b3ccb4d04a40dc7ad6e0275fda25fd75ae9d323e2465e202d"
|
||||
"sha256:aae25dc1ebe97c420f50b81fb0e5c949659af713f31fdb63c749ca68748f34b1",
|
||||
"sha256:f521bc2ac9a8e03c736f62911605c5d83970021e3fa95b37d769e2bbbe9b6172"
|
||||
],
|
||||
"version": "==0.18.0"
|
||||
"version": "==0.19.0"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
@ -1040,11 +1052,11 @@
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
|
||||
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
|
||||
"sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
|
||||
"sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.25.1"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==2.26.0"
|
||||
},
|
||||
"requests-oauthlib": {
|
||||
"hashes": [
|
||||
@ -1065,18 +1077,19 @@
|
||||
},
|
||||
"s3transfer": {
|
||||
"hashes": [
|
||||
"sha256:9b3752887a2880690ce628bc263d6d13a3864083aeacff4890c1c9839a5eb0bc",
|
||||
"sha256:cb022f4b16551edebbb31a377d3f09600dbada7363d8c5db7976e7f47732e1b2"
|
||||
"sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c",
|
||||
"sha256:9c1dc369814391a6bda20ebbf4b70a0f34630592c9aa520856bf384916af2803"
|
||||
],
|
||||
"version": "==0.4.2"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.5.0"
|
||||
},
|
||||
"sentry-sdk": {
|
||||
"hashes": [
|
||||
"sha256:c1227d38dca315ba35182373f129c3e2722e8ed999e52584e6aca7d287870739",
|
||||
"sha256:c7d380a21281e15be3d9f67a3c4fbb4f800c481d88ff8d8931f39486dd7b4ada"
|
||||
"sha256:5210a712dd57d88d225c1fc3fe3a3626fee493637bcd54e204826cf04b8d769c",
|
||||
"sha256:6864dcb6f7dec692635e5518c2a5c80010adf673c70340817f1a1b713d65bb41"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.0"
|
||||
"version": "==1.3.0"
|
||||
},
|
||||
"service-identity": {
|
||||
"hashes": [
|
||||
@ -1206,18 +1219,18 @@
|
||||
},
|
||||
"uvloop": {
|
||||
"hashes": [
|
||||
"sha256:114543c84e95df1b4ff546e6e3a27521580466a30127f12172a3278172ad68bc",
|
||||
"sha256:19fa1d56c91341318ac5d417e7b61c56e9a41183946cc70c411341173de02c69",
|
||||
"sha256:2bb0624a8a70834e54dde8feed62ed63b50bad7a1265c40d6403a2ac447bce01",
|
||||
"sha256:42eda9f525a208fbc4f7cecd00fa15c57cc57646c76632b3ba2fe005004f051d",
|
||||
"sha256:44cac8575bf168601424302045234d74e3561fbdbac39b2b54cc1d1d00b70760",
|
||||
"sha256:6de130d0cb78985a5d080e323b86c5ecaf3af82f4890492c05981707852f983c",
|
||||
"sha256:7ae39b11a5f4cec1432d706c21ecc62f9e04d116883178b09671aa29c46f7a47",
|
||||
"sha256:90e56f17755e41b425ad19a08c41dc358fa7bf1226c0f8e54d4d02d556f7af7c",
|
||||
"sha256:b45218c99795803fb8bdbc9435ff7f54e3a591b44cd4c121b02fa83affb61c7c",
|
||||
"sha256:e5e5f855c9bf483ee6cd1eb9a179b740de80cb0ae2988e3fa22309b78e2ea0e7"
|
||||
"sha256:0de811931e90ae2da9e19ce70ffad73047ab0c1dba7c6e74f9ae1a3aabeb89bd",
|
||||
"sha256:1ff05116ede1ebdd81802df339e5b1d4cab1dfbd99295bf27e90b4cec64d70e9",
|
||||
"sha256:2d8ffe44ae709f839c54bacf14ed283f41bee90430c3b398e521e10f8d117b3a",
|
||||
"sha256:5cda65fc60a645470b8525ce014516b120b7057b576fa876cdfdd5e60ab1efbb",
|
||||
"sha256:63a3288abbc9c8ee979d7e34c34e780b2fbab3e7e53d00b6c80271119f277399",
|
||||
"sha256:7522df4e45e4f25b50adbbbeb5bb9847495c438a628177099d2721f2751ff825",
|
||||
"sha256:7f4b8a905df909a407c5791fb582f6c03b0d3b491ecdc1cdceaefbc9bf9e08f6",
|
||||
"sha256:905f0adb0c09c9f44222ee02f6b96fd88b493478fffb7a345287f9444e926030",
|
||||
"sha256:ae2b325c0f6d748027f7463077e457006b4fdb35a8788f01754aadba825285ee",
|
||||
"sha256:e71fb9038bfcd7646ca126c5ef19b17e48d4af9e838b2bcfda7a9f55a6552a32"
|
||||
],
|
||||
"version": "==0.15.2"
|
||||
"version": "==0.15.3"
|
||||
},
|
||||
"vine": {
|
||||
"hashes": [
|
||||
@ -1423,11 +1436,11 @@
|
||||
},
|
||||
"astroid": {
|
||||
"hashes": [
|
||||
"sha256:38b95085e9d92e2ca06cf8b35c12a74fa81da395a6f9e65803742e6509c05892",
|
||||
"sha256:606b2911d10c3dcf35e58d2ee5c97360e8477d7b9f3efc3f24811c93e6fc2cd9"
|
||||
"sha256:7b963d1c590d490f60d2973e57437115978d3a2529843f160b5003b721e1e925",
|
||||
"sha256:83e494b02d75d07d4e347b27c066fd791c0c74fc96c613d1ea3de0c82c48168f"
|
||||
],
|
||||
"markers": "python_version ~= '3.6'",
|
||||
"version": "==2.6.2"
|
||||
"version": "==2.6.5"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
@ -1468,13 +1481,13 @@
|
||||
],
|
||||
"version": "==2021.5.30"
|
||||
},
|
||||
"chardet": {
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
|
||||
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
|
||||
"sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1",
|
||||
"sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==4.0.0"
|
||||
"markers": "python_version >= '3'",
|
||||
"version": "==2.0.3"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
@ -1568,10 +1581,10 @@
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
"sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
|
||||
"sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
|
||||
],
|
||||
"version": "==2.10"
|
||||
"version": "==3.2"
|
||||
},
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
@ -1582,11 +1595,11 @@
|
||||
},
|
||||
"isort": {
|
||||
"hashes": [
|
||||
"sha256:83510593e07e433b77bd5bff0f6f607dbafa06d1a89022616f02d8b699cfcd56",
|
||||
"sha256:8e2c107091cfec7286bc0f68a547d0ba4c094d460b732075b6fba674f1035c0c"
|
||||
"sha256:eed17b53c3e7912425579853d078a0832820f023191561fcee9d7cae424e0813",
|
||||
"sha256:f65ce5bd4cbc6abdfbe29afc2f0245538ab358c14590912df638033f157d555e"
|
||||
],
|
||||
"markers": "python_version < '4.0' and python_full_version >= '3.6.1'",
|
||||
"version": "==5.9.1"
|
||||
"markers": "python_version < '4' and python_full_version >= '3.6.1'",
|
||||
"version": "==5.9.2"
|
||||
},
|
||||
"lazy-object-proxy": {
|
||||
"hashes": [
|
||||
@ -1640,10 +1653,10 @@
|
||||
},
|
||||
"pathspec": {
|
||||
"hashes": [
|
||||
"sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd",
|
||||
"sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"
|
||||
"sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a",
|
||||
"sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"
|
||||
],
|
||||
"version": "==0.8.1"
|
||||
"version": "==0.9.0"
|
||||
},
|
||||
"pbr": {
|
||||
"hashes": [
|
||||
@ -1671,11 +1684,11 @@
|
||||
},
|
||||
"pylint": {
|
||||
"hashes": [
|
||||
"sha256:23a1dc8b30459d78e9ff25942c61bb936108ccbe29dd9e71c01dc8274961709a",
|
||||
"sha256:5d46330e6b8886c31b5e3aba5ff48c10f4aa5e76cbf9002c6544306221e63fbc"
|
||||
"sha256:1f333dc72ef7f5ea166b3230936ebcfb1f3b722e76c980cb9fe6b9f95e8d3172",
|
||||
"sha256:748f81e5776d6273a6619506e08f1b48ff9bcb8198366a56821cf11aac14fc87"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.9.3"
|
||||
"version": "==2.9.5"
|
||||
},
|
||||
"pylint-django": {
|
||||
"hashes": [
|
||||
@ -1753,53 +1766,57 @@
|
||||
},
|
||||
"regex": {
|
||||
"hashes": [
|
||||
"sha256:0e46c1191b2eb293a6912269ed08b4512e7e241bbf591f97e527492e04c77e93",
|
||||
"sha256:18040755606b0c21281493ec309214bd61e41a170509e5014f41d6a5a586e161",
|
||||
"sha256:1806370b2bef4d4193eebe8ee59a9fd7547836a34917b7badbe6561a8594d9cb",
|
||||
"sha256:1ccbd41dbee3a31e18938096510b7d4ee53aa9fce2ee3dcc8ec82ae264f6acfd",
|
||||
"sha256:1d386402ae7f3c9b107ae5863f7ecccb0167762c82a687ae6526b040feaa5ac6",
|
||||
"sha256:210c359e6ee5b83f7d8c529ba3c75ba405481d50f35a420609b0db827e2e3bb5",
|
||||
"sha256:268fe9dd1deb4a30c8593cabd63f7a241dfdc5bd9dd0233906c718db22cdd49a",
|
||||
"sha256:361be4d311ac995a8c7ad577025a3ae3a538531b1f2cf32efd8b7e5d33a13e5a",
|
||||
"sha256:3f7a92e60930f8fca2623d9e326c173b7cf2c8b7e4fdcf984b75a1d2fb08114d",
|
||||
"sha256:444723ebaeb7fa8125f29c01a31101a3854ac3de293e317944022ae5effa53a4",
|
||||
"sha256:494d0172774dc0beeea984b94c95389143db029575f7ca908edd74469321ea99",
|
||||
"sha256:4b1999ef60c45357598935c12508abf56edbbb9c380df6f336de38a6c3a294ae",
|
||||
"sha256:4fc86b729ab88fe8ac3ec92287df253c64aa71560d76da5acd8a2e245839c629",
|
||||
"sha256:5049d00dbb78f9d166d1c704e93934d42cce0570842bb1a61695123d6b01de09",
|
||||
"sha256:56bef6b414949e2c9acf96cb5d78de8b529c7b99752619494e78dc76f99fd005",
|
||||
"sha256:59845101de68fd5d3a1145df9ea022e85ecd1b49300ea68307ad4302320f6f61",
|
||||
"sha256:6b8b629f93246e507287ee07e26744beaffb4c56ed520576deac8b615bd76012",
|
||||
"sha256:6c72ebb72e64e9bd195cb35a9b9bbfb955fd953b295255b8ae3e4ad4a146b615",
|
||||
"sha256:7743798dfb573d006f1143d745bf17efad39775a5190b347da5d83079646be56",
|
||||
"sha256:78a2a885345a2d60b5e68099e877757d5ed12e46ba1e87507175f14f80892af3",
|
||||
"sha256:849802379a660206277675aa5a5c327f5c910c690649535863ddf329b0ba8c87",
|
||||
"sha256:8cf6728f89b071bd3ab37cb8a0e306f4de897553a0ed07442015ee65fbf53d62",
|
||||
"sha256:a1b6a3f600d6aff97e3f28c34192c9ed93fee293bd96ef327b64adb51a74b2f6",
|
||||
"sha256:a548bb51c4476332ce4139df8e637386730f79a92652a907d12c696b6252b64d",
|
||||
"sha256:a8a5826d8a1b64e2ff9af488cc179e1a4d0f144d11ce486a9f34ea38ccedf4ef",
|
||||
"sha256:b024ee43ee6b310fad5acaee23e6485b21468718cb792a9d1693eecacc3f0b7e",
|
||||
"sha256:b092754c06852e8a8b022004aff56c24b06310189186805800d09313c37ce1f8",
|
||||
"sha256:b1dbeef938281f240347d50f28ae53c4b046a23389cd1fc4acec5ea0eae646a1",
|
||||
"sha256:bf819c5b77ff44accc9a24e31f1f7ceaaf6c960816913ed3ef8443b9d20d81b6",
|
||||
"sha256:c11f2fca544b5e30a0e813023196a63b1cb9869106ef9a26e9dae28bce3e4e26",
|
||||
"sha256:ce269e903b00d1ab4746793e9c50a57eec5d5388681abef074d7b9a65748fca5",
|
||||
"sha256:d0cf2651a8804f6325747c7e55e3be0f90ee2848e25d6b817aa2728d263f9abb",
|
||||
"sha256:e07e92935040c67f49571779d115ecb3e727016d42fb36ee0d8757db4ca12ee0",
|
||||
"sha256:e80d2851109e56420b71f9702ad1646e2f0364528adbf6af85527bc61e49f394",
|
||||
"sha256:ed77b97896312bc2deafe137ca2626e8b63808f5bedb944f73665c68093688a7",
|
||||
"sha256:f32f47fb22c988c0b35756024b61d156e5c4011cb8004aa53d93b03323c45657",
|
||||
"sha256:fdad3122b69cdabdb3da4c2a4107875913ac78dab0117fc73f988ad589c66b66"
|
||||
"sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f",
|
||||
"sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad",
|
||||
"sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a",
|
||||
"sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf",
|
||||
"sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59",
|
||||
"sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d",
|
||||
"sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895",
|
||||
"sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4",
|
||||
"sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3",
|
||||
"sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222",
|
||||
"sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0",
|
||||
"sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c",
|
||||
"sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417",
|
||||
"sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d",
|
||||
"sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d",
|
||||
"sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761",
|
||||
"sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0",
|
||||
"sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026",
|
||||
"sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854",
|
||||
"sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb",
|
||||
"sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d",
|
||||
"sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068",
|
||||
"sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde",
|
||||
"sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d",
|
||||
"sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec",
|
||||
"sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa",
|
||||
"sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd",
|
||||
"sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b",
|
||||
"sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26",
|
||||
"sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2",
|
||||
"sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f",
|
||||
"sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694",
|
||||
"sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0",
|
||||
"sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407",
|
||||
"sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874",
|
||||
"sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035",
|
||||
"sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d",
|
||||
"sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c",
|
||||
"sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5",
|
||||
"sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985",
|
||||
"sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58"
|
||||
],
|
||||
"version": "==2021.7.1"
|
||||
"version": "==2021.7.6"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
|
||||
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
|
||||
"sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
|
||||
"sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.25.1"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==2.26.0"
|
||||
},
|
||||
"requests-mock": {
|
||||
"hashes": [
|
||||
|
@ -2,10 +2,13 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
(.x being the latest patch release for each version)
|
||||
|
||||
| Version | Supported |
|
||||
| ---------- | ------------------ |
|
||||
| 2021.4.x | :white_check_mark: |
|
||||
| 2021.5.x | :white_check_mark: |
|
||||
| 2021.6.x | :white_check_mark: |
|
||||
| 2021.7.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
@ -1,3 +1,3 @@
|
||||
"""authentik"""
|
||||
__version__ = "2021.6.4"
|
||||
__version__ = "2021.7.1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
@ -49,7 +49,7 @@ class ConfigView(APIView):
|
||||
caps.append(Capabilities.CAN_GEO_IP)
|
||||
if SERVICE_HOST_ENV_NAME in environ:
|
||||
# Running in k8s, only s3 backup is supported
|
||||
if CONFIG.y("postgresql.s3_backup"):
|
||||
if CONFIG.y_bool("postgresql.s3_backup"):
|
||||
caps.append(Capabilities.CAN_BACKUP)
|
||||
else:
|
||||
# Running in compose, backup is always supported
|
||||
|
38
authentik/api/v2/sentry.py
Normal file
38
authentik/api/v2/sentry.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""Sentry tunnel"""
|
||||
from json import loads
|
||||
|
||||
from django.conf import settings
|
||||
from django.http.request import HttpRequest
|
||||
from django.http.response import HttpResponse
|
||||
from django.views.generic.base import View
|
||||
from requests import post
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class SentryTunnelView(View):
|
||||
"""Sentry tunnel, to prevent ad blockers from blocking sentry"""
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Sentry tunnel, to prevent ad blockers from blocking sentry"""
|
||||
# Only allow usage of this endpoint when error reporting is enabled
|
||||
if not CONFIG.y_bool("error_reporting.enabled", False):
|
||||
return HttpResponse(status=400)
|
||||
# Body is 2 json objects separated by \n
|
||||
full_body = request.body
|
||||
header = loads(full_body.splitlines()[0])
|
||||
# Check that the DSN is what we expect
|
||||
dsn = header.get("dsn", "")
|
||||
if dsn != settings.SENTRY_DSN:
|
||||
return HttpResponse(status=400)
|
||||
response = post(
|
||||
"https://sentry.beryju.org/api/8/envelope/",
|
||||
data=full_body,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except RequestException:
|
||||
return HttpResponse(status=500)
|
||||
return HttpResponse(status=response.status_code)
|
@ -1,5 +1,6 @@
|
||||
"""api v2 urls"""
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from drf_spectacular.views import SpectacularAPIView
|
||||
from rest_framework import routers
|
||||
|
||||
@ -10,6 +11,7 @@ from authentik.admin.api.tasks import TaskViewSet
|
||||
from authentik.admin.api.version import VersionView
|
||||
from authentik.admin.api.workers import WorkerView
|
||||
from authentik.api.v2.config import ConfigView
|
||||
from authentik.api.v2.sentry import SentryTunnelView
|
||||
from authentik.api.views import APIBrowserView
|
||||
from authentik.core.api.applications import ApplicationViewSet
|
||||
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
|
||||
@ -235,6 +237,7 @@ urlpatterns = (
|
||||
FlowExecutorView.as_view(),
|
||||
name="flow-executor",
|
||||
),
|
||||
path("sentry/", csrf_exempt(SentryTunnelView.as_view()), name="sentry"),
|
||||
path("schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
]
|
||||
)
|
||||
|
@ -114,23 +114,23 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["GET"])
|
||||
# pylint: disable=unused-argument
|
||||
def check_access(self, request: Request, slug: str) -> Response:
|
||||
"""Check access to a single application by slug"""
|
||||
# Don't use self.get_object as that checks for view_application permission
|
||||
# which the user might not have, even if they have access
|
||||
application = get_object_or_404(Application, slug=slug)
|
||||
# If the current user is superuser, they can set `for_user`
|
||||
for_user = self.request.user
|
||||
if self.request.user.is_superuser and "for_user" in request.data:
|
||||
for_user = get_object_or_404(User, pk=request.data.get("for_user"))
|
||||
engine = PolicyEngine(application, for_user, self.request)
|
||||
for_user = request.user
|
||||
if request.user.is_superuser and "for_user" in request.query_params:
|
||||
for_user = get_object_or_404(User, pk=request.query_params.get("for_user"))
|
||||
engine = PolicyEngine(application, for_user, request)
|
||||
engine.use_cache = False
|
||||
engine.build()
|
||||
result = engine.result
|
||||
response = PolicyTestResultSerializer(PolicyResult(False))
|
||||
if result.passing:
|
||||
response = PolicyTestResultSerializer(PolicyResult(True))
|
||||
if self.request.user.is_superuser:
|
||||
if request.user.is_superuser:
|
||||
response = PolicyTestResultSerializer(result)
|
||||
return Response(response.data)
|
||||
|
||||
@ -145,18 +145,20 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
def list(self, request: Request) -> Response:
|
||||
"""Custom list method that checks Policy based access instead of guardian"""
|
||||
self.request.session.pop(USER_LOGIN_AUTHENTICATED, None)
|
||||
queryset = self._filter_queryset_for_list(self.get_queryset())
|
||||
self.paginate_queryset(queryset)
|
||||
|
||||
should_cache = request.GET.get("search", "") == ""
|
||||
|
||||
superuser_full_list = (
|
||||
str(request.GET.get("superuser_full_list", "false")).lower() == "true"
|
||||
)
|
||||
if superuser_full_list and request.user.is_superuser:
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
return super().list(request)
|
||||
|
||||
# To prevent the user from having to double login when prompt is set to login
|
||||
# and the user has just signed it. This session variable is set in the UserLoginStage
|
||||
# and is (quite hackily) removed from the session in applications's API's List method
|
||||
self.request.session.pop(USER_LOGIN_AUTHENTICATED, None)
|
||||
queryset = self._filter_queryset_for_list(self.get_queryset())
|
||||
self.paginate_queryset(queryset)
|
||||
|
||||
allowed_applications = []
|
||||
if not should_cache:
|
||||
|
@ -1,24 +1,81 @@
|
||||
"""Groups API Viewset"""
|
||||
from django.db.models.query import QuerySet
|
||||
from rest_framework.fields import JSONField
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from django_filters.filters import ModelMultipleChoiceFilter
|
||||
from django_filters.filterset import FilterSet
|
||||
from rest_framework.fields import BooleanField, CharField, JSONField
|
||||
from rest_framework.serializers import ListSerializer, ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import is_dict
|
||||
from authentik.core.models import Group
|
||||
from authentik.core.models import Group, User
|
||||
|
||||
|
||||
class GroupMemberSerializer(ModelSerializer):
|
||||
"""Stripped down user serializer to show relevant users for groups"""
|
||||
|
||||
is_superuser = BooleanField(read_only=True)
|
||||
avatar = CharField(read_only=True)
|
||||
attributes = JSONField(validators=[is_dict], required=False)
|
||||
uid = CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = User
|
||||
fields = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"is_superuser",
|
||||
"email",
|
||||
"avatar",
|
||||
"attributes",
|
||||
"uid",
|
||||
]
|
||||
|
||||
|
||||
class GroupSerializer(ModelSerializer):
|
||||
"""Group Serializer"""
|
||||
|
||||
attributes = JSONField(validators=[is_dict], required=False)
|
||||
users_obj = ListSerializer(
|
||||
child=GroupMemberSerializer(), read_only=True, source="users", required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Group
|
||||
fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"]
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"is_superuser",
|
||||
"parent",
|
||||
"users",
|
||||
"attributes",
|
||||
"users_obj",
|
||||
]
|
||||
|
||||
|
||||
class GroupFilter(FilterSet):
|
||||
"""Filter for groups"""
|
||||
|
||||
members_by_username = ModelMultipleChoiceFilter(
|
||||
field_name="users__username",
|
||||
to_field_name="username",
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
members_by_pk = ModelMultipleChoiceFilter(
|
||||
field_name="users",
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Group
|
||||
fields = ["name", "is_superuser", "members_by_pk", "members_by_username"]
|
||||
|
||||
|
||||
class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
@ -27,7 +84,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = Group.objects.all()
|
||||
serializer_class = GroupSerializer
|
||||
search_fields = ["name", "is_superuser"]
|
||||
filterset_fields = ["name", "is_superuser"]
|
||||
filterset_class = GroupFilter
|
||||
ordering = ["name"]
|
||||
|
||||
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
||||
|
@ -12,7 +12,7 @@ from authentik.api.decorators import permission_required
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import Token, TokenIntents
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.managed.api import ManagedSerializer
|
||||
|
||||
@ -61,11 +61,19 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
"intent",
|
||||
"user__username",
|
||||
"description",
|
||||
"expires",
|
||||
"expiring",
|
||||
]
|
||||
ordering = ["expires"]
|
||||
|
||||
def perform_create(self, serializer: TokenSerializer):
|
||||
serializer.save(user=self.request.user, intent=TokenIntents.INTENT_API)
|
||||
serializer.save(
|
||||
user=self.request.user,
|
||||
intent=TokenIntents.INTENT_API,
|
||||
expiring=self.request.user.attributes.get(
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING, True
|
||||
),
|
||||
)
|
||||
|
||||
@permission_required("authentik_core.view_token_key")
|
||||
@extend_schema(
|
||||
|
@ -128,7 +128,14 @@ class UsersFilter(FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["username", "name", "is_active", "is_superuser", "attributes"]
|
||||
fields = [
|
||||
"username",
|
||||
"email",
|
||||
"name",
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
"attributes",
|
||||
]
|
||||
|
||||
|
||||
class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
@ -136,7 +143,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
queryset = User.objects.none()
|
||||
serializer_class = UserSerializer
|
||||
search_fields = ["username", "name", "is_active"]
|
||||
search_fields = ["username", "name", "is_active", "email"]
|
||||
filterset_class = UsersFilter
|
||||
|
||||
def get_queryset(self): # pragma: no cover
|
||||
@ -206,6 +213,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
if self.request.user.has_perm("authentik_core.view_group"):
|
||||
if self.request.user.has_perm("authentik_core.view_user"):
|
||||
return self._filter_queryset_for_list(queryset)
|
||||
return super().filter_queryset(queryset)
|
||||
|
@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-09 17:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0025_alter_application_meta_icon"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="application",
|
||||
name="meta_icon",
|
||||
field=models.FileField(
|
||||
default=None, max_length=500, null=True, upload_to="application-icons/"
|
||||
),
|
||||
),
|
||||
]
|
@ -37,6 +37,8 @@ LOGGER = get_logger()
|
||||
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
|
||||
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
|
||||
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec
|
||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
|
||||
|
||||
GRAVATAR_URL = "https://secure.gravatar.com"
|
||||
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
|
||||
@ -228,7 +230,10 @@ class Application(PolicyBindingModel):
|
||||
)
|
||||
# For template applications, this can be set to /static/authentik/applications/*
|
||||
meta_icon = models.FileField(
|
||||
upload_to="application-icons/", default=None, null=True
|
||||
upload_to="application-icons/",
|
||||
default=None,
|
||||
null=True,
|
||||
max_length=500,
|
||||
)
|
||||
meta_description = models.TextField(default="", blank=True)
|
||||
meta_publisher = models.TextField(default="", blank=True)
|
||||
@ -377,6 +382,13 @@ class ExpiringModel(models.Model):
|
||||
expires = models.DateTimeField(default=default_token_duration)
|
||||
expiring = models.BooleanField(default=True)
|
||||
|
||||
def expire_action(self, *args, **kwargs):
|
||||
"""Handler which is called when this object is expired. By
|
||||
default the object is deleted. This is less efficient compared
|
||||
to bulk deleting objects, but classes like Token() need to change
|
||||
values instead of being deleted."""
|
||||
return self.delete(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def filter_not_expired(cls, **kwargs) -> QuerySet:
|
||||
"""Filer for tokens which are not expired yet or are not expiring,
|
||||
@ -421,6 +433,18 @@ class Token(ManagedModel, ExpiringModel):
|
||||
user = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+")
|
||||
description = models.TextField(default="", blank=True)
|
||||
|
||||
def expire_action(self, *args, **kwargs):
|
||||
"""Handler which is called when this object is expired."""
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
self.key = default_token_key()
|
||||
self.save(*args, **kwargs)
|
||||
Event.new(
|
||||
action=EventAction.SECRET_ROTATE,
|
||||
token=self,
|
||||
message=f"Token {self.identifier}'s secret was rotated.",
|
||||
).save()
|
||||
|
||||
def __str__(self):
|
||||
description = f"{self.identifier}"
|
||||
if self.expiring:
|
||||
@ -467,8 +491,8 @@ class PropertyMapping(SerializerModel, ManagedModel):
|
||||
evaluator.set_context(user, request, self, **kwargs)
|
||||
try:
|
||||
return evaluator.evaluate(self.expression)
|
||||
except (ValueError, SyntaxError) as exc:
|
||||
raise PropertyMappingExpressionException from exc
|
||||
except Exception as exc:
|
||||
raise PropertyMappingExpressionException(str(exc)) from exc
|
||||
|
||||
def __str__(self):
|
||||
return f"Property Mapping {self.name}"
|
||||
|
@ -141,11 +141,11 @@ class SourceFlowManager:
|
||||
self._logger.info("denying source because user exists", user=user)
|
||||
return Action.DENY, None
|
||||
# Should never get here as default enroll case is returned above.
|
||||
return Action.DENY, None
|
||||
return Action.DENY, None # pragma: no cover
|
||||
|
||||
def update_connection(
|
||||
self, connection: UserSourceConnection, **kwargs
|
||||
) -> UserSourceConnection:
|
||||
) -> UserSourceConnection: # pragma: no cover
|
||||
"""Optionally make changes to the connection after it is looked up/created."""
|
||||
return connection
|
||||
|
||||
@ -178,7 +178,7 @@ class SourceFlowManager:
|
||||
% {"source": self.source.name}
|
||||
),
|
||||
)
|
||||
return redirect("/")
|
||||
return redirect(reverse("authentik_core:root-redirect"))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||
|
@ -26,14 +26,16 @@ def clean_expired_models(self: MonitoredTask):
|
||||
messages = []
|
||||
for cls in ExpiringModel.__subclasses__():
|
||||
cls: ExpiringModel
|
||||
amount, _ = (
|
||||
objects = (
|
||||
cls.objects.all()
|
||||
.exclude(expiring=False)
|
||||
.exclude(expiring=True, expires__gt=now())
|
||||
.delete()
|
||||
)
|
||||
LOGGER.debug("Deleted expired models", model=cls, amount=amount)
|
||||
messages.append(f"Deleted {amount} expired {cls._meta.verbose_name_plural}")
|
||||
for obj in objects:
|
||||
obj.expire_action()
|
||||
amount = objects.count()
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
|
||||
|
||||
|
||||
|
@ -31,7 +31,7 @@ class TestPropertyMappings(TestCase):
|
||||
"""Test expression error"""
|
||||
expr = "return aaa"
|
||||
mapping = PropertyMapping.objects.create(name="test", expression=expr)
|
||||
with self.assertRaises(NameError):
|
||||
with self.assertRaises(PropertyMappingExpressionException):
|
||||
mapping.evaluate(None, None)
|
||||
events = Event.objects.filter(
|
||||
action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr
|
||||
@ -44,7 +44,7 @@ class TestPropertyMappings(TestCase):
|
||||
expr = "return aaa"
|
||||
request = self.factory.get("/")
|
||||
mapping = PropertyMapping.objects.create(name="test", expression=expr)
|
||||
with self.assertRaises(NameError):
|
||||
with self.assertRaises(PropertyMappingExpressionException):
|
||||
mapping.evaluate(get_anonymous_user(), request)
|
||||
events = Event.objects.filter(
|
||||
action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr
|
||||
|
160
authentik/core/tests/test_source_flow_manager.py
Normal file
160
authentik/core/tests/test_source_flow_manager.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""Test Source flow_manager"""
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.messages.middleware import MessageMiddleware
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.http.request import HttpRequest
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from guardian.utils import get_anonymous_user
|
||||
|
||||
from authentik.core.models import SourceUserMatchingModes, User
|
||||
from authentik.core.sources.flow_manager import Action
|
||||
from authentik.flows.tests.test_planner import dummy_get_response
|
||||
from authentik.providers.oauth2.generators import generate_client_id
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.views.callback import OAuthSourceFlowManager
|
||||
|
||||
|
||||
class TestSourceFlowManager(TestCase):
|
||||
"""Test Source flow_manager"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.source = OAuthSource.objects.create(name="test")
|
||||
self.factory = RequestFactory()
|
||||
self.identifier = generate_client_id()
|
||||
|
||||
def get_request(self, user: User) -> HttpRequest:
|
||||
"""Helper to create a get request with session and message middleware"""
|
||||
request = self.factory.get("/")
|
||||
request.user = user
|
||||
middleware = SessionMiddleware(dummy_get_response)
|
||||
middleware.process_request(request)
|
||||
request.session.save()
|
||||
middleware = MessageMiddleware(dummy_get_response)
|
||||
middleware.process_request(request)
|
||||
request.session.save()
|
||||
return request
|
||||
|
||||
def test_unauthenticated_enroll(self):
|
||||
"""Test un-authenticated user enrolling"""
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, self.get_request(AnonymousUser()), self.identifier, {}
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.ENROLL)
|
||||
flow_manager.get_flow()
|
||||
|
||||
def test_unauthenticated_auth(self):
|
||||
"""Test un-authenticated user authenticating"""
|
||||
UserOAuthSourceConnection.objects.create(
|
||||
user=get_anonymous_user(), source=self.source, identifier=self.identifier
|
||||
)
|
||||
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, self.get_request(AnonymousUser()), self.identifier, {}
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.AUTH)
|
||||
flow_manager.get_flow()
|
||||
|
||||
def test_authenticated_link(self):
|
||||
"""Test authenticated user linking"""
|
||||
UserOAuthSourceConnection.objects.create(
|
||||
user=get_anonymous_user(), source=self.source, identifier=self.identifier
|
||||
)
|
||||
user = User.objects.create(username="foo", email="foo@bar.baz")
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, self.get_request(user), self.identifier, {}
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.LINK)
|
||||
flow_manager.get_flow()
|
||||
|
||||
def test_unauthenticated_enroll_email(self):
|
||||
"""Test un-authenticated user enrolling (link on email)"""
|
||||
User.objects.create(username="foo", email="foo@bar.baz")
|
||||
self.source.user_matching_mode = SourceUserMatchingModes.EMAIL_LINK
|
||||
|
||||
# Without email, deny
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, self.get_request(AnonymousUser()), self.identifier, {}
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.DENY)
|
||||
flow_manager.get_flow()
|
||||
# With email
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
self.get_request(AnonymousUser()),
|
||||
self.identifier,
|
||||
{"email": "foo@bar.baz"},
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.LINK)
|
||||
flow_manager.get_flow()
|
||||
|
||||
def test_unauthenticated_enroll_username(self):
|
||||
"""Test un-authenticated user enrolling (link on username)"""
|
||||
User.objects.create(username="foo", email="foo@bar.baz")
|
||||
self.source.user_matching_mode = SourceUserMatchingModes.USERNAME_LINK
|
||||
|
||||
# Without username, deny
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, self.get_request(AnonymousUser()), self.identifier, {}
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.DENY)
|
||||
flow_manager.get_flow()
|
||||
# With username
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
self.get_request(AnonymousUser()),
|
||||
self.identifier,
|
||||
{"username": "foo"},
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.LINK)
|
||||
flow_manager.get_flow()
|
||||
|
||||
def test_unauthenticated_enroll_username_deny(self):
|
||||
"""Test un-authenticated user enrolling (deny on username)"""
|
||||
User.objects.create(username="foo", email="foo@bar.baz")
|
||||
self.source.user_matching_mode = SourceUserMatchingModes.USERNAME_DENY
|
||||
|
||||
# With non-existent username, enroll
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
self.get_request(AnonymousUser()),
|
||||
self.identifier,
|
||||
{
|
||||
"username": "bar",
|
||||
},
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.ENROLL)
|
||||
flow_manager.get_flow()
|
||||
# With username
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
self.get_request(AnonymousUser()),
|
||||
self.identifier,
|
||||
{"username": "foo"},
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.DENY)
|
||||
flow_manager.get_flow()
|
||||
|
||||
def test_unauthenticated_enroll_link_non_existent(self):
|
||||
"""Test un-authenticated user enrolling (link on username), username doesn't exist"""
|
||||
self.source.user_matching_mode = SourceUserMatchingModes.USERNAME_LINK
|
||||
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
self.get_request(AnonymousUser()),
|
||||
self.identifier,
|
||||
{"username": "foo"},
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.ENROLL)
|
||||
flow_manager.get_flow()
|
@ -1,18 +0,0 @@
|
||||
"""authentik core task tests"""
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.core.models import Token
|
||||
from authentik.core.tasks import clean_expired_models
|
||||
|
||||
|
||||
class TestTasks(TestCase):
|
||||
"""Test Tasks"""
|
||||
|
||||
def test_token_cleanup(self):
|
||||
"""Test Token cleanup task"""
|
||||
Token.objects.create(expires=now(), user=get_anonymous_user())
|
||||
self.assertEqual(Token.objects.all().count(), 1)
|
||||
clean_expired_models.delay().get()
|
||||
self.assertEqual(Token.objects.all().count(), 0)
|
@ -1,8 +1,16 @@
|
||||
"""Test token API"""
|
||||
from django.urls.base import reverse
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||
Token,
|
||||
TokenIntents,
|
||||
User,
|
||||
)
|
||||
from authentik.core.tasks import clean_expired_models
|
||||
|
||||
|
||||
class TestTokenAPI(APITestCase):
|
||||
@ -22,3 +30,25 @@ class TestTokenAPI(APITestCase):
|
||||
token = Token.objects.get(identifier="test-token")
|
||||
self.assertEqual(token.user, self.user)
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.expiring, True)
|
||||
|
||||
def test_token_create_non_expiring(self):
|
||||
"""Test token creation endpoint"""
|
||||
self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = False
|
||||
self.user.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:token-list"), {"identifier": "test-token"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
token = Token.objects.get(identifier="test-token")
|
||||
self.assertEqual(token.user, self.user)
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.expiring, False)
|
||||
|
||||
def test_token_expire(self):
|
||||
"""Test Token expire task"""
|
||||
token: Token = Token.objects.create(expires=now(), user=get_anonymous_user())
|
||||
key = token.key
|
||||
clean_expired_models.delay().get()
|
||||
token.refresh_from_db()
|
||||
self.assertNotEqual(key, token.key)
|
||||
|
47
authentik/events/migrations/0017_alter_event_action.py
Normal file
47
authentik/events/migrations/0017_alter_event_action.py
Normal file
@ -0,0 +1,47 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-14 19:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0016_add_tenant"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("user_write", "User Write"),
|
||||
("suspicious_request", "Suspicious Request"),
|
||||
("password_set", "Password Set"),
|
||||
("secret_view", "Secret View"),
|
||||
("secret_rotate", "Secret Rotate"),
|
||||
("invitation_used", "Invite Used"),
|
||||
("authorize_application", "Authorize Application"),
|
||||
("source_linked", "Source Linked"),
|
||||
("impersonation_started", "Impersonation Started"),
|
||||
("impersonation_ended", "Impersonation Ended"),
|
||||
("policy_execution", "Policy Execution"),
|
||||
("policy_exception", "Policy Exception"),
|
||||
("property_mapping_exception", "Property Mapping Exception"),
|
||||
("system_task_execution", "System Task Execution"),
|
||||
("system_task_exception", "System Task Exception"),
|
||||
("system_exception", "System Exception"),
|
||||
("configuration_error", "Configuration Error"),
|
||||
("model_created", "Model Created"),
|
||||
("model_updated", "Model Updated"),
|
||||
("model_deleted", "Model Deleted"),
|
||||
("email_sent", "Email Sent"),
|
||||
("update_available", "Update Available"),
|
||||
("custom_", "Custom Prefix"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
@ -24,8 +24,10 @@ from authentik.events.geo import GEOIP_READER
|
||||
from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.tenants.models import Tenant
|
||||
from authentik.tenants.utils import DEFAULT_TENANT
|
||||
|
||||
LOGGER = get_logger("authentik.events")
|
||||
@ -37,7 +39,8 @@ GAUGE_EVENTS = Gauge(
|
||||
|
||||
|
||||
def default_event_duration():
|
||||
"""Default duration an Event is saved"""
|
||||
"""Default duration an Event is saved.
|
||||
This is used as a fallback when no tenant is available"""
|
||||
return now() + timedelta(days=365)
|
||||
|
||||
|
||||
@ -62,6 +65,7 @@ class EventAction(models.TextChoices):
|
||||
PASSWORD_SET = "password_set" # noqa # nosec
|
||||
|
||||
SECRET_VIEW = "secret_view" # noqa # nosec
|
||||
SECRET_ROTATE = "secret_rotate" # noqa # nosec
|
||||
|
||||
INVITE_USED = "invitation_used"
|
||||
|
||||
@ -146,7 +150,12 @@ class Event(ExpiringModel):
|
||||
"method": request.method,
|
||||
}
|
||||
if hasattr(request, "tenant"):
|
||||
self.tenant = sanitize_dict(model_to_dict(request.tenant))
|
||||
tenant: Tenant = request.tenant
|
||||
# Because self.created only gets set on save, we can't use it's value here
|
||||
# hence we set self.created to now and then use it
|
||||
self.created = now()
|
||||
self.expires = self.created + timedelta_from_string(tenant.event_retention)
|
||||
self.tenant = sanitize_dict(model_to_dict(tenant))
|
||||
if hasattr(request, "user"):
|
||||
original_user = None
|
||||
if hasattr(request, "session"):
|
||||
@ -313,7 +322,8 @@ class NotificationTransport(models.Model):
|
||||
response = post(self.webhook_url, json=body)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
raise NotificationTransportError(exc.response.text) from exc
|
||||
text = exc.response.text if exc.response else str(exc)
|
||||
raise NotificationTransportError(text) from exc
|
||||
return [
|
||||
response.status_code,
|
||||
response.text,
|
||||
|
24
authentik/flows/migrations/0023_alter_flow_background.py
Normal file
24
authentik/flows/migrations/0023_alter_flow_background.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-09 17:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0022_alter_flowstagebinding_invalid_response_action"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="flow",
|
||||
name="background",
|
||||
field=models.FileField(
|
||||
default=None,
|
||||
help_text="Background shown during execution",
|
||||
max_length=500,
|
||||
null=True,
|
||||
upload_to="flow-backgrounds/",
|
||||
),
|
||||
),
|
||||
]
|
@ -121,6 +121,7 @@ class Flow(SerializerModel, PolicyBindingModel):
|
||||
default=None,
|
||||
null=True,
|
||||
help_text=_("Background shown during execution"),
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
compatibility_mode = models.BooleanField(
|
||||
|
@ -17,7 +17,7 @@ from authentik.flows.challenge import (
|
||||
WithUserInfoChallenge,
|
||||
)
|
||||
from authentik.flows.models import InvalidResponseAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.views import FlowExecutorView
|
||||
|
||||
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
|
||||
@ -102,12 +102,18 @@ class ChallengeStageView(StageView):
|
||||
return self.challenge_invalid(challenge)
|
||||
return self.challenge_valid(challenge)
|
||||
|
||||
def format_title(self) -> str:
|
||||
"""Allow usage of placeholder in flow title."""
|
||||
return self.executor.flow.title % {
|
||||
"app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "")
|
||||
}
|
||||
|
||||
def _get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
challenge = self.get_challenge(*args, **kwargs)
|
||||
if "flow_info" not in challenge.initial_data:
|
||||
flow_info = ContextualFlowInfo(
|
||||
data={
|
||||
"title": self.executor.flow.title,
|
||||
"title": self.format_title(),
|
||||
"background": self.executor.flow.background_url,
|
||||
"cancel_url": reverse("authentik_flows:cancel"),
|
||||
}
|
||||
|
@ -13,7 +13,10 @@ web:
|
||||
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password: ''
|
||||
tls: false
|
||||
tls_reqs: "none"
|
||||
cache_db: 0
|
||||
message_queue_db: 1
|
||||
ws_db: 2
|
||||
|
67
authentik/lib/tests/test_http.py
Normal file
67
authentik/lib/tests/test_http.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Test HTTP Helpers"""
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP,
|
||||
Token,
|
||||
TokenIntents,
|
||||
User,
|
||||
)
|
||||
from authentik.lib.utils.http import (
|
||||
OUTPOST_REMOTE_IP_HEADER,
|
||||
OUTPOST_TOKEN_HEADER,
|
||||
get_client_ip,
|
||||
)
|
||||
|
||||
|
||||
class TestHTTP(TestCase):
|
||||
"""Test HTTP Helpers"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.get(username="akadmin")
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_normal(self):
|
||||
"""Test normal request"""
|
||||
request = self.factory.get("/")
|
||||
self.assertEqual(get_client_ip(request), "127.0.0.1")
|
||||
|
||||
def test_forward_for(self):
|
||||
"""Test x-forwarded-for request"""
|
||||
request = self.factory.get("/", HTTP_X_FORWARDED_FOR="127.0.0.2")
|
||||
self.assertEqual(get_client_ip(request), "127.0.0.2")
|
||||
|
||||
def test_fake_outpost(self):
|
||||
"""Test faked IP which is overridden by an outpost"""
|
||||
token = Token.objects.create(
|
||||
identifier="test", user=self.user, intent=TokenIntents.INTENT_API
|
||||
)
|
||||
# Invalid, non-existant token
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
**{
|
||||
OUTPOST_REMOTE_IP_HEADER: "1.2.3.4",
|
||||
OUTPOST_TOKEN_HEADER: "abc",
|
||||
},
|
||||
)
|
||||
self.assertEqual(get_client_ip(request), "127.0.0.1")
|
||||
# Invalid, user doesn't have permisions
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
**{
|
||||
OUTPOST_REMOTE_IP_HEADER: "1.2.3.4",
|
||||
OUTPOST_TOKEN_HEADER: token.key,
|
||||
},
|
||||
)
|
||||
self.assertEqual(get_client_ip(request), "127.0.0.1")
|
||||
# Valid
|
||||
self.user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True
|
||||
self.user.save()
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
**{
|
||||
OUTPOST_REMOTE_IP_HEADER: "1.2.3.4",
|
||||
OUTPOST_TOKEN_HEADER: token.key,
|
||||
},
|
||||
)
|
||||
self.assertEqual(get_client_ip(request), "1.2.3.4")
|
@ -2,10 +2,12 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP"
|
||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
|
||||
OUTPOST_TOKEN_HEADER = "HTTP_X_AUTHENTIK_OUTPOST_TOKEN" # nosec
|
||||
DEFAULT_IP = "255.255.255.255"
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def _get_client_ip_from_meta(meta: dict[str, Any]) -> str:
|
||||
@ -27,23 +29,41 @@ def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]:
|
||||
"""Get the actual remote IP when set by an outpost. Only
|
||||
allowed when the request is authenticated, by a user with USER_ATTRIBUTE_CAN_OVERRIDE_IP set
|
||||
to outpost"""
|
||||
if not hasattr(request, "user"):
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP,
|
||||
Token,
|
||||
TokenIntents,
|
||||
)
|
||||
|
||||
if (
|
||||
OUTPOST_REMOTE_IP_HEADER not in request.META
|
||||
or OUTPOST_TOKEN_HEADER not in request.META
|
||||
):
|
||||
return None
|
||||
if not request.user.is_authenticated:
|
||||
fake_ip = request.META[OUTPOST_REMOTE_IP_HEADER]
|
||||
tokens = Token.filter_not_expired(
|
||||
key=request.META.get(OUTPOST_TOKEN_HEADER), intent=TokenIntents.INTENT_API
|
||||
)
|
||||
if not tokens.exists():
|
||||
LOGGER.warning("Attempted remote-ip override without token", fake_ip=fake_ip)
|
||||
return None
|
||||
if OUTPOST_REMOTE_IP_HEADER not in request.META:
|
||||
user = tokens.first().user
|
||||
if not user.group_attributes().get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False):
|
||||
LOGGER.warning(
|
||||
"Remote-IP override: user doesn't have permission",
|
||||
user=user,
|
||||
fake_ip=fake_ip,
|
||||
)
|
||||
return None
|
||||
if request.user.group_attributes().get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False):
|
||||
return None
|
||||
return request.META[OUTPOST_REMOTE_IP_HEADER]
|
||||
return fake_ip
|
||||
|
||||
|
||||
def get_client_ip(request: Optional[HttpRequest]) -> str:
|
||||
"""Attempt to get the client's IP by checking common HTTP Headers.
|
||||
Returns none if no IP Could be found"""
|
||||
if request:
|
||||
override = _get_outpost_override_ip(request)
|
||||
if override:
|
||||
return override
|
||||
return _get_client_ip_from_meta(request.META)
|
||||
return DEFAULT_IP
|
||||
if not request:
|
||||
return DEFAULT_IP
|
||||
override = _get_outpost_override_ip(request)
|
||||
if override:
|
||||
return override
|
||||
return _get_client_ip_from_meta(request.META)
|
||||
|
@ -5,12 +5,12 @@ from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
ALLOWED_KEYS = (
|
||||
"days",
|
||||
"seconds",
|
||||
"microseconds",
|
||||
"milliseconds",
|
||||
"seconds",
|
||||
"minutes",
|
||||
"hours",
|
||||
"days",
|
||||
"weeks",
|
||||
)
|
||||
|
||||
|
@ -77,6 +77,7 @@ class OutpostSerializer(ModelSerializer):
|
||||
"service_connection_obj",
|
||||
"token_identifier",
|
||||
"config",
|
||||
"managed",
|
||||
]
|
||||
extra_kwargs = {"type": {"required": True}}
|
||||
|
||||
|
18
authentik/outposts/managed.py
Normal file
18
authentik/outposts/managed.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Outpost managed objects"""
|
||||
from authentik.managed.manager import EnsureExists, ObjectManager
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
|
||||
|
||||
class OutpostManager(ObjectManager):
|
||||
"""Outpost managed objects"""
|
||||
|
||||
def reconcile(self):
|
||||
return [
|
||||
EnsureExists(
|
||||
Outpost,
|
||||
"goauthentik.io/outposts/inbuilt",
|
||||
name="authentik Bundeled Outpost",
|
||||
object_field="name",
|
||||
type=OutpostType.PROXY,
|
||||
),
|
||||
]
|
24
authentik/outposts/migrations/0017_outpost_managed.py
Normal file
24
authentik/outposts/migrations/0017_outpost_managed.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-23 19:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0016_alter_outpost_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="outpost",
|
||||
name="managed",
|
||||
field=models.TextField(
|
||||
default=None,
|
||||
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="Managed by authentik",
|
||||
),
|
||||
),
|
||||
]
|
@ -28,12 +28,19 @@ from structlog.stdlib import get_logger
|
||||
from urllib3.exceptions import HTTPError
|
||||
|
||||
from authentik import ENV_GIT_HASH_KEY, __version__
|
||||
from authentik.core.models import USER_ATTRIBUTE_SA, Provider, Token, TokenIntents, User
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP,
|
||||
USER_ATTRIBUTE_SA,
|
||||
Provider,
|
||||
Token,
|
||||
TokenIntents,
|
||||
User,
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import InheritanceForeignKey
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.http import USER_ATTRIBUTE_CAN_OVERRIDE_IP
|
||||
from authentik.managed.models import ManagedModel
|
||||
from authentik.outposts.controllers.k8s.utils import get_namespace
|
||||
from authentik.outposts.docker_tls import DockerInlineTLS
|
||||
|
||||
@ -281,7 +288,7 @@ class KubernetesServiceConnection(OutpostServiceConnection):
|
||||
verbose_name_plural = _("Kubernetes Service-Connections")
|
||||
|
||||
|
||||
class Outpost(models.Model):
|
||||
class Outpost(ManagedModel):
|
||||
"""Outpost instance which manages a service user and token"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
|
||||
@ -337,12 +344,13 @@ class Outpost(models.Model):
|
||||
users = User.objects.filter(username=self.user_identifier)
|
||||
if not users.exists():
|
||||
user: User = User.objects.create(username=self.user_identifier)
|
||||
user.attributes[USER_ATTRIBUTE_SA] = True
|
||||
user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
else:
|
||||
user = users.first()
|
||||
user.attributes[USER_ATTRIBUTE_SA] = True
|
||||
user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True
|
||||
user.save()
|
||||
# To ensure the user only has the correct permissions, we delete all of them and re-add
|
||||
# the ones the user needs
|
||||
with transaction.atomic():
|
||||
|
@ -0,0 +1,49 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-14 19:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_policies_event_matcher", "0017_alter_eventmatcherpolicy_action"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="eventmatcherpolicy",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("user_write", "User Write"),
|
||||
("suspicious_request", "Suspicious Request"),
|
||||
("password_set", "Password Set"),
|
||||
("secret_view", "Secret View"),
|
||||
("secret_rotate", "Secret Rotate"),
|
||||
("invitation_used", "Invite Used"),
|
||||
("authorize_application", "Authorize Application"),
|
||||
("source_linked", "Source Linked"),
|
||||
("impersonation_started", "Impersonation Started"),
|
||||
("impersonation_ended", "Impersonation Ended"),
|
||||
("policy_execution", "Policy Execution"),
|
||||
("policy_exception", "Policy Exception"),
|
||||
("property_mapping_exception", "Property Mapping Exception"),
|
||||
("system_task_execution", "System Task Execution"),
|
||||
("system_task_exception", "System Task Exception"),
|
||||
("system_exception", "System Exception"),
|
||||
("configuration_error", "Configuration Error"),
|
||||
("model_created", "Model Created"),
|
||||
("model_updated", "Model Updated"),
|
||||
("model_deleted", "Model Deleted"),
|
||||
("email_sent", "Email Sent"),
|
||||
("update_available", "Update Available"),
|
||||
("custom_", "Custom Prefix"),
|
||||
],
|
||||
help_text="Match created events with this action type. When left empty, all action types will be matched.",
|
||||
),
|
||||
),
|
||||
]
|
@ -21,12 +21,15 @@ def update_score(request: HttpRequest, username: str, amount: int):
|
||||
"""Update score for IP and User"""
|
||||
remote_ip = get_client_ip(request)
|
||||
|
||||
# We only update the cache here, as its faster than writing to the DB
|
||||
cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0, CACHE_TIMEOUT)
|
||||
cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount)
|
||||
try:
|
||||
# We only update the cache here, as its faster than writing to the DB
|
||||
cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0, CACHE_TIMEOUT)
|
||||
cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount)
|
||||
|
||||
cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0, CACHE_TIMEOUT)
|
||||
cache.incr(CACHE_KEY_USER_PREFIX + username, amount)
|
||||
cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0, CACHE_TIMEOUT)
|
||||
cache.incr(CACHE_KEY_USER_PREFIX + username, amount)
|
||||
except ValueError as exc:
|
||||
LOGGER.warning("failed to set reputation", exc=exc)
|
||||
|
||||
LOGGER.debug("Updated score", amount=amount, for_user=username, for_ip=remote_ip)
|
||||
|
||||
|
@ -17,6 +17,10 @@ class LDAPProviderSerializer(ProviderSerializer):
|
||||
fields = ProviderSerializer.Meta.fields + [
|
||||
"base_dn",
|
||||
"search_group",
|
||||
"certificate",
|
||||
"tls_server_name",
|
||||
"uid_start_number",
|
||||
"gid_start_number",
|
||||
]
|
||||
|
||||
|
||||
@ -44,6 +48,10 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
|
||||
"bind_flow_slug",
|
||||
"application_slug",
|
||||
"search_group",
|
||||
"certificate",
|
||||
"tls_server_name",
|
||||
"uid_start_number",
|
||||
"gid_start_number",
|
||||
]
|
||||
|
||||
|
||||
|
@ -11,4 +11,5 @@ class LDAPDockerController(DockerController):
|
||||
super().__init__(outpost, connection)
|
||||
self.deployment_ports = [
|
||||
DeploymentPort(389, "ldap", "tcp", 3389),
|
||||
DeploymentPort(636, "ldaps", "tcp", 6636),
|
||||
]
|
||||
|
@ -11,4 +11,5 @@ class LDAPKubernetesController(KubernetesController):
|
||||
super().__init__(outpost, connection)
|
||||
self.deployment_ports = [
|
||||
DeploymentPort(389, "ldap", "tcp", 3389),
|
||||
DeploymentPort(636, "ldaps", "tcp", 6636),
|
||||
]
|
||||
|
@ -0,0 +1,30 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-13 11:38
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0002_create_self_signed_kp"),
|
||||
("authentik_providers_ldap", "0002_ldapprovider_search_group"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ldapprovider",
|
||||
name="certificate",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ldapprovider",
|
||||
name="tls_server_name",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-13 21:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_ldap", "0003_auto_20210713_1138"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ldapprovider",
|
||||
name="gid_start_number",
|
||||
field=models.IntegerField(
|
||||
default=4000,
|
||||
help_text="The start for gidNumbers, this number is added to a number generated from the group.Pk to make sure that the numbers aren't too low for POSIX groups. Default is 4000 to ensure that we don't collide with local groups or users primary groups gidNumber",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ldapprovider",
|
||||
name="uid_start_number",
|
||||
field=models.IntegerField(
|
||||
default=2000,
|
||||
help_text="The start for uidNumbers, this number is added to the user.Pk to make sure that the numbers aren't too low for POSIX users. Default is 2000 to ensure that we don't collide with local users uidNumber",
|
||||
),
|
||||
),
|
||||
]
|
@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import Group, Provider
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.outposts.models import OutpostModel
|
||||
|
||||
|
||||
@ -28,6 +29,36 @@ class LDAPProvider(OutpostModel, Provider):
|
||||
),
|
||||
)
|
||||
|
||||
tls_server_name = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
)
|
||||
certificate = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
uid_start_number = models.IntegerField(
|
||||
default=2000,
|
||||
help_text=_(
|
||||
"The start for uidNumbers, this number is added to the user.Pk to make sure that the "
|
||||
"numbers aren't too low for POSIX users. Default is 2000 to ensure that we don't "
|
||||
"collide with local users uidNumber"
|
||||
),
|
||||
)
|
||||
|
||||
gid_start_number = models.IntegerField(
|
||||
default=4000,
|
||||
help_text=_(
|
||||
"The start for gidNumbers, this number is added to a number generated from the "
|
||||
"group.Pk to make sure that the numbers aren't too low for POSIX groups. Default "
|
||||
"is 4000 to ensure that we don't collide with local groups or users "
|
||||
"primary groups gidNumber"
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def launch_url(self) -> Optional[str]:
|
||||
"""LDAP never has a launch URL"""
|
||||
|
@ -109,6 +109,7 @@ class Migration(migrations.Migration):
|
||||
"redirect_uris",
|
||||
models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
help_text="Enter each URI on a new line.",
|
||||
verbose_name="Redirect URIs",
|
||||
),
|
||||
|
@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-20 22:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0015_auto_20210703_1313"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="authorizationcode",
|
||||
name="nonce",
|
||||
field=models.TextField(default=None, null=True, verbose_name="Nonce"),
|
||||
),
|
||||
]
|
@ -158,6 +158,7 @@ class OAuth2Provider(Provider):
|
||||
)
|
||||
redirect_uris = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
verbose_name=_("Redirect URIs"),
|
||||
help_text=_("Enter each URI on a new line."),
|
||||
)
|
||||
@ -337,7 +338,7 @@ class AuthorizationCode(ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Authorization Code"""
|
||||
|
||||
code = models.CharField(max_length=255, unique=True, verbose_name=_("Code"))
|
||||
nonce = models.TextField(blank=True, default="", verbose_name=_("Nonce"))
|
||||
nonce = models.TextField(null=True, default=None, verbose_name=_("Nonce"))
|
||||
is_open_id = models.BooleanField(
|
||||
default=False, verbose_name=_("Is Authentication?")
|
||||
)
|
||||
|
@ -67,7 +67,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
|
||||
def test_redirect_uri(self):
|
||||
def test_invalid_redirect_uri(self):
|
||||
"""test missing/invalid redirect URI"""
|
||||
OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
@ -91,6 +91,28 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
|
||||
def test_empty_redirect_uri(self):
|
||||
"""test empty redirect URI (configure in provider)"""
|
||||
OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=Flow.objects.first(),
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get(
|
||||
"/", data={"response_type": "code", "client_id": "test"}
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://localhost",
|
||||
},
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
|
||||
def test_response_type(self):
|
||||
"""test response_type"""
|
||||
OAuth2Provider.objects.create(
|
||||
|
111
authentik/providers/oauth2/tests/test_userinfo.py
Normal file
111
authentik/providers/oauth2/tests/test_userinfo.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Test userinfo view"""
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.providers.oauth2.generators import (
|
||||
generate_client_id,
|
||||
generate_client_secret,
|
||||
)
|
||||
from authentik.providers.oauth2.models import (
|
||||
IDToken,
|
||||
OAuth2Provider,
|
||||
RefreshToken,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
class TestUserinfo(OAuthTestCase):
|
||||
"""Test token view"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
ObjectManager().run()
|
||||
self.app = Application.objects.create(name="test", slug="test")
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id=generate_client_id(),
|
||||
client_secret=generate_client_secret(),
|
||||
authorization_flow=Flow.objects.first(),
|
||||
redirect_uris="",
|
||||
rsa_key=CertificateKeyPair.objects.first(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
self.app.provider = self.provider
|
||||
self.app.save()
|
||||
self.user = User.objects.get(username="akadmin")
|
||||
self.token: RefreshToken = RefreshToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
access_token=generate_client_id(),
|
||||
refresh_token=generate_client_id(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
IDToken("foo", "bar"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_userinfo_normal(self):
|
||||
"""test user info with all normal scopes"""
|
||||
res = self.client.get(
|
||||
reverse("authentik_providers_oauth2:userinfo"),
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}",
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
force_str(res.content),
|
||||
{
|
||||
"name": "authentik Default Admin",
|
||||
"given_name": "authentik Default Admin",
|
||||
"family_name": "",
|
||||
"preferred_username": "akadmin",
|
||||
"nickname": "akadmin",
|
||||
"groups": ["authentik Admins"],
|
||||
"sub": "bar",
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_userinfo_invalid_scope(self):
|
||||
"""test user info with a broken scope"""
|
||||
scope = ScopeMapping.objects.create(
|
||||
name="test", scope_name="openid", expression="q"
|
||||
)
|
||||
self.provider.property_mappings.add(scope)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_providers_oauth2:userinfo"),
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}",
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
force_str(res.content),
|
||||
{
|
||||
"name": "authentik Default Admin",
|
||||
"given_name": "authentik Default Admin",
|
||||
"family_name": "",
|
||||
"preferred_username": "akadmin",
|
||||
"nickname": "akadmin",
|
||||
"groups": ["authentik Admins"],
|
||||
"sub": "bar",
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
events = Event.objects.filter(
|
||||
action=EventAction.CONFIGURATION_ERROR,
|
||||
)
|
||||
self.assertTrue(events.exists())
|
||||
self.assertEqual(
|
||||
events.first().context["message"],
|
||||
"Failed to evaluate property-mapping: name 'q' is not defined",
|
||||
)
|
@ -156,20 +156,23 @@ class OAuthAuthorizationParams:
|
||||
|
||||
def check_redirect_uri(self):
|
||||
"""Redirect URI validation."""
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
if not self.redirect_uri:
|
||||
LOGGER.warning("Missing redirect uri.")
|
||||
raise RedirectUriError("", self.provider.redirect_uris.split())
|
||||
if self.redirect_uri.lower() not in [
|
||||
x.lower() for x in self.provider.redirect_uris.split()
|
||||
]:
|
||||
raise RedirectUriError("", allowed_redirect_urls)
|
||||
if len(allowed_redirect_urls) < 1:
|
||||
LOGGER.warning(
|
||||
"Provider has no allowed redirect_uri set, allowing all.",
|
||||
allow=self.redirect_uri.lower(),
|
||||
)
|
||||
return
|
||||
if self.redirect_uri.lower() not in [x.lower() for x in allowed_redirect_urls]:
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect_uri=self.redirect_uri,
|
||||
excepted=self.provider.redirect_uris.split(),
|
||||
)
|
||||
raise RedirectUriError(
|
||||
self.redirect_uri, self.provider.redirect_uris.split()
|
||||
excepted=allowed_redirect_urls,
|
||||
)
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
if self.request:
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "request_not_supported", self.grant_type, self.state
|
||||
@ -189,6 +192,10 @@ class OAuthAuthorizationParams:
|
||||
|
||||
def check_nonce(self):
|
||||
"""Nonce parameter validation."""
|
||||
# https://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDTValidation
|
||||
# Nonce is only required for Implicit flows
|
||||
if self.grant_type != GrantTypes.IMPLICIT:
|
||||
return
|
||||
if not self.nonce:
|
||||
self.nonce = self.state
|
||||
LOGGER.warning("Using state as nonce for OpenID Request")
|
||||
|
@ -126,7 +126,15 @@ class TokenParams:
|
||||
LOGGER.warning("Missing authorization code")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
if self.redirect_uri not in self.provider.redirect_uris.split():
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
if len(allowed_redirect_urls) < 1:
|
||||
LOGGER.warning(
|
||||
"Provider has no allowed redirect_uri set, allowing all.",
|
||||
allow=self.redirect_uri.lower(),
|
||||
)
|
||||
elif self.redirect_uri.lower() not in [
|
||||
x.lower() for x in allowed_redirect_urls
|
||||
]:
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
uri=self.redirect_uri,
|
||||
|
@ -7,6 +7,8 @@ from django.http.response import HttpResponseBadRequest
|
||||
from django.views import View
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.providers.oauth2.constants import (
|
||||
SCOPE_GITHUB_ORG_READ,
|
||||
SCOPE_GITHUB_USER,
|
||||
@ -63,12 +65,20 @@ class UserInfoView(View):
|
||||
for scope in ScopeMapping.objects.filter(
|
||||
provider=token.provider, scope_name__in=scopes_from_client
|
||||
).order_by("scope_name"):
|
||||
value = scope.evaluate(
|
||||
user=token.user,
|
||||
request=self.request,
|
||||
provider=token.provider,
|
||||
token=token,
|
||||
)
|
||||
value = None
|
||||
try:
|
||||
value = scope.evaluate(
|
||||
user=token.user,
|
||||
request=self.request,
|
||||
provider=token.provider,
|
||||
token=token,
|
||||
)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=f"Failed to evaluate property-mapping: {str(exc)}",
|
||||
mapping=scope,
|
||||
).from_http(self.request)
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
|
@ -90,12 +90,6 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
||||
"""Proxy provider serializer for outposts"""
|
||||
|
||||
oidc_configuration = SerializerMethodField()
|
||||
forward_auth_mode = SerializerMethodField()
|
||||
|
||||
def get_forward_auth_mode(self, instance: ProxyProvider) -> bool:
|
||||
"""Legacy field for 2021.5 outposts"""
|
||||
# TODO: remove in 2021.7
|
||||
return instance.mode in [ProxyMode.FORWARD_SINGLE, ProxyMode.FORWARD_DOMAIN]
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -117,8 +111,6 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
||||
"basic_auth_user_attribute",
|
||||
"mode",
|
||||
"cookie_domain",
|
||||
# Legacy field, remove in 2021.7
|
||||
"forward_auth_mode",
|
||||
]
|
||||
|
||||
@extend_schema_field(OpenIDConnectConfigurationSerializer)
|
||||
|
@ -113,6 +113,7 @@ class TraefikMiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware])
|
||||
authResponseHeaders=[
|
||||
"Set-Cookie",
|
||||
"X-Auth-Username",
|
||||
"X-Auth-Groups",
|
||||
"X-Forwarded-Email",
|
||||
"X-Forwarded-Preferred-Username",
|
||||
"X-Forwarded-User",
|
||||
|
@ -9,6 +9,7 @@ from lxml.etree import Element, SubElement # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.request_parser import AuthNRequest
|
||||
@ -99,7 +100,11 @@ class AssertionProcessor:
|
||||
attribute_statement.append(attribute)
|
||||
|
||||
except PropertyMappingExpressionException as exc:
|
||||
LOGGER.warning(str(exc))
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=f"Failed to evaluate property-mapping: {str(exc)}",
|
||||
mapping=mapping,
|
||||
).from_http(self.http_request)
|
||||
continue
|
||||
return attribute_statement
|
||||
|
||||
@ -161,7 +166,11 @@ class AssertionProcessor:
|
||||
name_id.text = value
|
||||
return name_id
|
||||
except PropertyMappingExpressionException as exc:
|
||||
LOGGER.warning(str(exc))
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=f"Failed to evaluate property-mapping: {str(exc)}",
|
||||
mapping=self.provider.name_id_mapping,
|
||||
).from_http(self.http_request)
|
||||
return name_id
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_EMAIL:
|
||||
name_id.text = self.http_request.user.email
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""SAML AuthNRequest Parser and dataclass"""
|
||||
from base64 import b64decode
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import xmlsec
|
||||
@ -54,7 +54,9 @@ class AuthNRequestParser:
|
||||
def __init__(self, provider: SAMLProvider):
|
||||
self.provider = provider
|
||||
|
||||
def _parse_xml(self, decoded_xml: str, relay_state: Optional[str]) -> AuthNRequest:
|
||||
def _parse_xml(
|
||||
self, decoded_xml: Union[str, bytes], relay_state: Optional[str]
|
||||
) -> AuthNRequest:
|
||||
root = ElementTree.fromstring(decoded_xml)
|
||||
|
||||
request_acs_url = root.attrib["AssertionConsumerServiceURL"]
|
||||
@ -79,10 +81,12 @@ class AuthNRequestParser:
|
||||
|
||||
return auth_n_request
|
||||
|
||||
def parse(self, saml_request: str, relay_state: Optional[str]) -> AuthNRequest:
|
||||
def parse(
|
||||
self, saml_request: str, relay_state: Optional[str] = None
|
||||
) -> AuthNRequest:
|
||||
"""Validate and parse raw request with enveloped signautre."""
|
||||
try:
|
||||
decoded_xml = b64decode(saml_request.encode()).decode()
|
||||
decoded_xml = b64decode(saml_request.encode())
|
||||
except UnicodeDecodeError:
|
||||
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST)
|
||||
|
||||
@ -93,8 +97,9 @@ class AuthNRequestParser:
|
||||
signature_nodes = root.xpath(
|
||||
"/samlp:AuthnRequest/ds:Signature", namespaces=NS_MAP
|
||||
)
|
||||
if len(signature_nodes) != 1:
|
||||
raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT)
|
||||
# No signatures, no verifier configured -> decode xml directly
|
||||
if len(signature_nodes) < 1 and not verifier:
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
|
||||
signature_node = signature_nodes[0]
|
||||
|
||||
|
@ -6,21 +6,40 @@ from django.http.request import QueryDict
|
||||
from django.test import RequestFactory, TestCase
|
||||
from guardian.utils import get_anonymous_user
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.flows.tests.test_planner import dummy_get_response
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
||||
from authentik.providers.saml.processors.request_parser import AuthNRequestParser
|
||||
from authentik.sources.saml.exceptions import MismatchedRequestID
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
from authentik.sources.saml.processors.constants import SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
)
|
||||
from authentik.sources.saml.processors.request import (
|
||||
SESSION_REQUEST_ID,
|
||||
RequestProcessor,
|
||||
)
|
||||
from authentik.sources.saml.processors.response import ResponseProcessor
|
||||
|
||||
POST_REQUEST = (
|
||||
"PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOkF1dGhuUmVxdWVzdCB4bWxuczpzYW1sMn"
|
||||
"A9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpwcm90b2NvbCIgQXNzZXJ0aW9uQ29uc3VtZXJTZXJ2aWNlVVJMPSJo"
|
||||
"dHRwczovL2V1LWNlbnRyYWwtMS5zaWduaW4uYXdzLmFtYXpvbi5jb20vcGxhdGZvcm0vc2FtbC9hY3MvMmQ3MzdmOTYtNT"
|
||||
"VmYi00MDM1LTk1M2UtNWUyNDEzNGViNzc4IiBEZXN0aW5hdGlvbj0iaHR0cHM6Ly9pZC5iZXJ5anUub3JnL2FwcGxpY2F0"
|
||||
"aW9uL3NhbWwvYXdzLXNzby9zc28vYmluZGluZy9wb3N0LyIgSUQ9ImF3c19MRHhMR2V1YnBjNWx4MTJneENnUzZ1UGJpeD"
|
||||
"F5ZDVyZSIgSXNzdWVJbnN0YW50PSIyMDIxLTA3LTA2VDE0OjIzOjA2LjM4OFoiIFByb3RvY29sQmluZGluZz0idXJuOm9h"
|
||||
"c2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmJpbmRpbmdzOkhUVFAtUE9TVCIgVmVyc2lvbj0iMi4wIj48c2FtbDI6SXNzdWVyIH"
|
||||
"htbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj5odHRwczovL2V1LWNlbnRyYWwt"
|
||||
"MS5zaWduaW4uYXdzLmFtYXpvbi5jb20vcGxhdGZvcm0vc2FtbC9kLTk5NjcyZjgyNzg8L3NhbWwyOklzc3Vlcj48c2FtbD"
|
||||
"JwOk5hbWVJRFBvbGljeSBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjEuMTpuYW1laWQtZm9ybWF0OmVtYWls"
|
||||
"QWRkcmVzcyIvPjwvc2FtbDJwOkF1dGhuUmVxdWVzdD4="
|
||||
)
|
||||
REDIRECT_REQUEST = (
|
||||
"fZLNbsIwEIRfJfIdbKeFgEUipXAoEm0jSHvopTLJplhK7NTr9Oft6yRUKhekPdk73+yOdoWyqVuRdu6k9/DRAbrgu6k1iu"
|
||||
"EjJp3VwkhUKLRsAIUrxCF92IlwykRrjTOFqUmQIoJ1yui10dg1YA9gP1UBz/tdTE7OtSgo5WzKQzYditGeP8GW9rSQZk+H"
|
||||
@ -63,6 +82,7 @@ class TestAuthNRequest(TestCase):
|
||||
"""Test AuthN Request generator and parser"""
|
||||
|
||||
def setUp(self):
|
||||
ObjectManager().run()
|
||||
cert = CertificateKeyPair.objects.first()
|
||||
self.provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
authorization_flow=Flow.objects.get(
|
||||
@ -208,3 +228,79 @@ class TestAuthNRequest(TestCase):
|
||||
self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab")
|
||||
self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_UNSPECIFIED)
|
||||
self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE)
|
||||
|
||||
def test_signed_static(self):
|
||||
"""Test post request with static request"""
|
||||
provider = SAMLProvider(
|
||||
name="aws",
|
||||
authorization_flow=Flow.objects.get(
|
||||
slug="default-provider-authorization-implicit-consent"
|
||||
),
|
||||
acs_url=(
|
||||
"https://eu-central-1.signin.aws.amazon.com/platform/"
|
||||
"saml/acs/2d737f96-55fb-4035-953e-5e24134eb778"
|
||||
),
|
||||
audience="https://10.120.20.200/saml-sp/SAML2/POST",
|
||||
issuer="https://10.120.20.200/saml-sp/SAML2/POST",
|
||||
signing_kp=CertificateKeyPair.objects.first(),
|
||||
)
|
||||
parsed_request = AuthNRequestParser(provider).parse(POST_REQUEST)
|
||||
self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
|
||||
self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL)
|
||||
|
||||
def test_request_attributes(self):
|
||||
"""Test full SAML Request/Response flow, fully signed"""
|
||||
http_request = self.factory.get("/")
|
||||
http_request.user = User.objects.get(username="akadmin")
|
||||
|
||||
middleware = SessionMiddleware(dummy_get_response)
|
||||
middleware.process_request(http_request)
|
||||
http_request.session.save()
|
||||
|
||||
# First create an AuthNRequest
|
||||
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
||||
request = request_proc.build_auth_n()
|
||||
|
||||
# To get an assertion we need a parsed request (parsed by provider)
|
||||
parsed_request = AuthNRequestParser(self.provider).parse(
|
||||
b64encode(request.encode()).decode(), "test_state"
|
||||
)
|
||||
# Now create a response and convert it to string (provider)
|
||||
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
|
||||
self.assertIn("akadmin", response_proc.build_response())
|
||||
|
||||
def test_request_attributes_invalid(self):
|
||||
"""Test full SAML Request/Response flow, fully signed"""
|
||||
http_request = self.factory.get("/")
|
||||
http_request.user = User.objects.get(username="akadmin")
|
||||
|
||||
middleware = SessionMiddleware(dummy_get_response)
|
||||
middleware.process_request(http_request)
|
||||
http_request.session.save()
|
||||
|
||||
# First create an AuthNRequest
|
||||
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
||||
request = request_proc.build_auth_n()
|
||||
|
||||
# Create invalid PropertyMapping
|
||||
scope = SAMLPropertyMapping.objects.create(
|
||||
name="test", saml_name="test", expression="q"
|
||||
)
|
||||
self.provider.property_mappings.add(scope)
|
||||
|
||||
# To get an assertion we need a parsed request (parsed by provider)
|
||||
parsed_request = AuthNRequestParser(self.provider).parse(
|
||||
b64encode(request.encode()).decode(), "test_state"
|
||||
)
|
||||
# Now create a response and convert it to string (provider)
|
||||
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
|
||||
self.assertIn("akadmin", response_proc.build_response())
|
||||
|
||||
events = Event.objects.filter(
|
||||
action=EventAction.CONFIGURATION_ERROR,
|
||||
)
|
||||
self.assertTrue(events.exists())
|
||||
self.assertEqual(
|
||||
events.first().context["message"],
|
||||
"Failed to evaluate property-mapping: name 'q' is not defined",
|
||||
)
|
||||
|
@ -27,6 +27,7 @@ class ChannelsStorage(BaseStorage):
|
||||
uid,
|
||||
{
|
||||
"type": "event.update",
|
||||
"message_type": "message",
|
||||
"level": message.level_tag,
|
||||
"tags": message.tags,
|
||||
"message": message.message,
|
||||
|
94
authentik/root/middleware.py
Normal file
94
authentik/root/middleware.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""Dynamically set SameSite depending if the upstream connection is TLS or not"""
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sessions.backends.base import UpdateError
|
||||
from django.contrib.sessions.exceptions import SessionInterrupted
|
||||
from django.contrib.sessions.middleware import (
|
||||
SessionMiddleware as UpstreamSessionMiddleware,
|
||||
)
|
||||
from django.http.request import HttpRequest
|
||||
from django.http.response import HttpResponse
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from django.utils.http import http_date
|
||||
|
||||
|
||||
class SessionMiddleware(UpstreamSessionMiddleware):
|
||||
"""Dynamically set SameSite depending if the upstream connection is TLS or not"""
|
||||
|
||||
@staticmethod
|
||||
def is_secure(request: HttpRequest) -> bool:
|
||||
"""Check if request is TLS'd or localhost"""
|
||||
if request.is_secure():
|
||||
return True
|
||||
host, _, _ = request.get_host().partition(":")
|
||||
if host == "localhost" and settings.DEBUG:
|
||||
# Since go does not consider localhost with http a secure origin
|
||||
# we can't set the secure flag.
|
||||
user_agent = request.META.get("HTTP_USER_AGENT", "")
|
||||
if user_agent.startswith("authentik-outpost@"):
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
def process_response(
|
||||
self, request: HttpRequest, response: HttpResponse
|
||||
) -> HttpResponse:
|
||||
"""
|
||||
If request.session was modified, or if the configuration is to save the
|
||||
session every time, save the changes and set a session cookie or delete
|
||||
the session cookie if the session has been emptied.
|
||||
"""
|
||||
try:
|
||||
accessed = request.session.accessed
|
||||
modified = request.session.modified
|
||||
empty = request.session.is_empty()
|
||||
except AttributeError:
|
||||
return response
|
||||
# Set SameSite based on whether or not the request is secure
|
||||
secure = SessionMiddleware.is_secure(request)
|
||||
same_site = "None" if secure else "Lax"
|
||||
# First check if we need to delete this cookie.
|
||||
# The session should be deleted only if the session is entirely empty.
|
||||
if settings.SESSION_COOKIE_NAME in request.COOKIES and empty:
|
||||
response.delete_cookie(
|
||||
settings.SESSION_COOKIE_NAME,
|
||||
path=settings.SESSION_COOKIE_PATH,
|
||||
domain=settings.SESSION_COOKIE_DOMAIN,
|
||||
samesite=same_site,
|
||||
)
|
||||
patch_vary_headers(response, ("Cookie",))
|
||||
else:
|
||||
if accessed:
|
||||
patch_vary_headers(response, ("Cookie",))
|
||||
if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty:
|
||||
if request.session.get_expire_at_browser_close():
|
||||
max_age = None
|
||||
expires = None
|
||||
else:
|
||||
max_age = request.session.get_expiry_age()
|
||||
expires_time = time.time() + max_age
|
||||
expires = http_date(expires_time)
|
||||
# Save the session data and refresh the client cookie.
|
||||
# Skip session save for 500 responses, refs #3881.
|
||||
if response.status_code != 500:
|
||||
try:
|
||||
request.session.save()
|
||||
except UpdateError:
|
||||
raise SessionInterrupted(
|
||||
"The request's session was deleted before the "
|
||||
"request completed. The user may have logged "
|
||||
"out in a concurrent request, for example."
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.SESSION_COOKIE_NAME,
|
||||
request.session.session_key,
|
||||
max_age=max_age,
|
||||
expires=expires,
|
||||
domain=settings.SESSION_COOKIE_DOMAIN,
|
||||
path=settings.SESSION_COOKIE_PATH,
|
||||
secure=secure,
|
||||
httponly=settings.SESSION_COOKIE_HTTPONLY or None,
|
||||
samesite=same_site,
|
||||
)
|
||||
return response
|
@ -188,12 +188,19 @@ REST_FRAMEWORK = {
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
}
|
||||
|
||||
REDIS_PROTOCOL_PREFIX = "redis://"
|
||||
REDIS_CELERY_TLS_REQUIREMENTS = ""
|
||||
if CONFIG.y_bool("redis.tls", False):
|
||||
REDIS_PROTOCOL_PREFIX = "rediss://"
|
||||
REDIS_CELERY_TLS_REQUIREMENTS = f"?ssl_cert_reqs={CONFIG.y('redis.tls_reqs')}"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": (
|
||||
f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379"
|
||||
f"/{CONFIG.y('redis.cache_db')}"
|
||||
f"{REDIS_PROTOCOL_PREFIX}:"
|
||||
f"{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:"
|
||||
f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.cache_db')}"
|
||||
),
|
||||
"TIMEOUT": int(CONFIG.y("redis.cache_timeout", 300)),
|
||||
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
|
||||
@ -203,14 +210,16 @@ DJANGO_REDIS_IGNORE_EXCEPTIONS = True
|
||||
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
SESSION_COOKIE_SAMESITE = "lax"
|
||||
# Configured via custom SessionMiddleware
|
||||
# SESSION_COOKIE_SAMESITE = "None"
|
||||
# SESSION_COOKIE_SECURE = True
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
|
||||
MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage"
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"authentik.root.middleware.SessionMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"authentik.core.middleware.RequestIDMiddleware",
|
||||
"authentik.tenants.middleware.TenantMiddleware",
|
||||
@ -250,8 +259,9 @@ CHANNEL_LAYERS = {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [
|
||||
f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379"
|
||||
f"/{CONFIG.y('redis.ws_db')}"
|
||||
f"{REDIS_PROTOCOL_PREFIX}:"
|
||||
f"{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:"
|
||||
f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.ws_db')}"
|
||||
],
|
||||
},
|
||||
},
|
||||
@ -329,12 +339,16 @@ CELERY_BEAT_SCHEDULE = {
|
||||
CELERY_TASK_CREATE_MISSING_QUEUES = True
|
||||
CELERY_TASK_DEFAULT_QUEUE = "authentik"
|
||||
CELERY_BROKER_URL = (
|
||||
f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}"
|
||||
f":6379/{CONFIG.y('redis.message_queue_db')}"
|
||||
f"{REDIS_PROTOCOL_PREFIX}:"
|
||||
f"{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:"
|
||||
f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.message_queue_db')}"
|
||||
f"{REDIS_CELERY_TLS_REQUIREMENTS}"
|
||||
)
|
||||
CELERY_RESULT_BACKEND = (
|
||||
f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}"
|
||||
f":6379/{CONFIG.y('redis.message_queue_db')}"
|
||||
f"{REDIS_PROTOCOL_PREFIX}:"
|
||||
f"{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:"
|
||||
f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.message_queue_db')}"
|
||||
f"{REDIS_CELERY_TLS_REQUIREMENTS}"
|
||||
)
|
||||
|
||||
# Database backup
|
||||
@ -362,11 +376,12 @@ if CONFIG.y("postgresql.s3_backup"):
|
||||
)
|
||||
|
||||
# Sentry integration
|
||||
SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8"
|
||||
_ERROR_REPORTING = CONFIG.y_bool("error_reporting.enabled", False)
|
||||
if _ERROR_REPORTING:
|
||||
# pylint: disable=abstract-class-instantiated
|
||||
sentry_init(
|
||||
dsn="https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8",
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=[
|
||||
DjangoIntegration(transaction_style="function_name"),
|
||||
CeleryIntegration(),
|
||||
@ -401,9 +416,8 @@ MEDIA_URL = "/media/"
|
||||
|
||||
TEST = False
|
||||
TEST_RUNNER = "authentik.root.test_runner.PytestTestRunner"
|
||||
|
||||
LOG_LEVEL = CONFIG.y("log_level").upper() if not TEST else "DEBUG"
|
||||
|
||||
# We can't check TEST here as its set later by the test runner
|
||||
LOG_LEVEL = CONFIG.y("log_level").upper() if "TF_BUILD" not in os.environ else "DEBUG"
|
||||
|
||||
structlog.configure_once(
|
||||
processors=[
|
||||
|
@ -5,6 +5,7 @@ from django.db.models.query import QuerySet
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
|
||||
@ -83,6 +84,11 @@ class BaseLDAPSynchronizer:
|
||||
else:
|
||||
properties[object_field] = self._flatten(value)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=f"Failed to evaluate property-mapping: {str(exc)}",
|
||||
mapping=mapping,
|
||||
).save()
|
||||
self._logger.warning(
|
||||
"Mapping failed to evaluate", exc=exc, mapping=mapping
|
||||
)
|
||||
|
@ -5,6 +5,7 @@ from django.db.models import Q
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.providers.oauth2.generators import generate_client_secret
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
@ -31,6 +32,33 @@ class LDAPSyncTests(TestCase):
|
||||
additional_group_dn="ou=groups",
|
||||
)
|
||||
|
||||
def test_sync_error(self):
|
||||
"""Test user sync"""
|
||||
self.source.property_mappings.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||
| Q(managed__startswith="goauthentik.io/sources/ldap/ms")
|
||||
)
|
||||
)
|
||||
mapping = LDAPPropertyMapping.objects.create(
|
||||
name="name",
|
||||
object_field="name",
|
||||
expression="q",
|
||||
)
|
||||
self.source.property_mappings.set([mapping])
|
||||
self.source.save()
|
||||
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
user_sync = UserLDAPSynchronizer(self.source)
|
||||
user_sync.sync()
|
||||
self.assertFalse(User.objects.filter(username="user0_sn").exists())
|
||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||
events = Event.objects.filter(
|
||||
action=EventAction.CONFIGURATION_ERROR,
|
||||
context__message="Failed to evaluate property-mapping: name 'q' is not defined",
|
||||
)
|
||||
self.assertTrue(events.exists())
|
||||
|
||||
def test_sync_users_ad(self):
|
||||
"""Test user sync"""
|
||||
self.source.property_mappings.set(
|
||||
|
@ -38,6 +38,7 @@ class TenantSerializer(ModelSerializer):
|
||||
"flow_invalidation",
|
||||
"flow_recovery",
|
||||
"flow_unenrollment",
|
||||
"event_retention",
|
||||
]
|
||||
|
||||
|
||||
|
@ -3,6 +3,7 @@ from typing import Callable
|
||||
|
||||
from django.http.request import HttpRequest
|
||||
from django.http.response import HttpResponse
|
||||
from sentry_sdk.api import set_tag
|
||||
|
||||
from authentik.tenants.utils import get_tenant_for_request
|
||||
|
||||
@ -19,4 +20,6 @@ class TenantMiddleware:
|
||||
if not hasattr(request, "tenant"):
|
||||
tenant = get_tenant_for_request(request)
|
||||
setattr(request, "tenant", tenant)
|
||||
set_tag("authentik.tenant_uuid", tenant.tenant_uuid.hex)
|
||||
set_tag("authentik.tenant_domain", tenant.domain)
|
||||
return self.get_response(request)
|
||||
|
24
authentik/tenants/migrations/0004_tenant_event_retention.py
Normal file
24
authentik/tenants/migrations/0004_tenant_event_retention.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-24 17:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import authentik.lib.utils.time
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_tenants", "0003_tenant_branding_favicon"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tenant",
|
||||
name="event_retention",
|
||||
field=models.TextField(
|
||||
default="days=365",
|
||||
help_text="Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).",
|
||||
validators=[authentik.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
]
|
@ -5,6 +5,7 @@ from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
|
||||
class Tenant(models.Model):
|
||||
@ -22,6 +23,7 @@ class Tenant(models.Model):
|
||||
)
|
||||
|
||||
branding_title = models.TextField(default="authentik")
|
||||
|
||||
branding_logo = models.TextField(
|
||||
default="/static/dist/assets/icons/icon_left_brand.svg"
|
||||
)
|
||||
@ -40,6 +42,17 @@ class Tenant(models.Model):
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_unenrollment"
|
||||
)
|
||||
|
||||
event_retention = models.TextField(
|
||||
default="days=365",
|
||||
validators=[timedelta_string_validator],
|
||||
help_text=_(
|
||||
(
|
||||
"Events will be deleted after this duration."
|
||||
"(Format: weeks=3;days=2;hours=3,seconds=2)."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.default:
|
||||
return "Default tenant"
|
||||
|
@ -1,9 +1,12 @@
|
||||
"""Test tenants"""
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
@ -57,3 +60,28 @@ class TestTenants(TestCase):
|
||||
"ui_footer_links": CONFIG.y("footer_links"),
|
||||
},
|
||||
)
|
||||
|
||||
def test_event_retention(self):
|
||||
"""Test tenant's event retention"""
|
||||
tenant = Tenant.objects.create(
|
||||
domain="foo",
|
||||
default=True,
|
||||
branding_title="custom",
|
||||
event_retention="weeks=3",
|
||||
)
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
request.tenant = tenant
|
||||
event = Event.new(
|
||||
action=EventAction.SYSTEM_EXCEPTION, message="test"
|
||||
).from_http(request)
|
||||
self.assertEqual(
|
||||
event.expires.day, (event.created + timedelta_from_string("weeks=3")).day
|
||||
)
|
||||
self.assertEqual(
|
||||
event.expires.month,
|
||||
(event.created + timedelta_from_string("weeks=3")).month,
|
||||
)
|
||||
self.assertEqual(
|
||||
event.expires.year, (event.created + timedelta_from_string("weeks=3")).year
|
||||
)
|
||||
|
@ -6,12 +6,6 @@ trigger:
|
||||
- next
|
||||
- version-*
|
||||
|
||||
variables:
|
||||
${{ if startsWith(variables['Build.SourceBranch'], 'refs/pull/') }}:
|
||||
branchName: ${{ replace(variables['System.PullRequest.SourceBranch'], '/', '-') }}
|
||||
${{ if startsWith(variables['Build.SourceBranch'], 'refs/heads/') }}:
|
||||
branchName: ${{ replace(variables['Build.SourceBranchName'], 'refs/heads/', '') }}
|
||||
|
||||
stages:
|
||||
- stage: generate
|
||||
jobs:
|
||||
@ -27,7 +21,7 @@ stages:
|
||||
script: make gen-outpost
|
||||
- task: PublishPipelineArtifact@1
|
||||
inputs:
|
||||
targetPath: 'outpost/api/'
|
||||
targetPath: 'api/'
|
||||
artifact: 'go_api_client'
|
||||
publishLocation: 'pipeline'
|
||||
- stage: lint
|
||||
@ -43,17 +37,19 @@ stages:
|
||||
inputs:
|
||||
buildType: 'current'
|
||||
artifactName: 'go_api_client'
|
||||
path: "outpost/api/"
|
||||
path: "api/"
|
||||
- task: CmdLine@2
|
||||
inputs:
|
||||
script: |
|
||||
mkdir -p web/dist
|
||||
mkdir -p website/help
|
||||
touch web/dist/test website/help/test
|
||||
docker run \
|
||||
--rm \
|
||||
-v $(pwd):/app \
|
||||
-w /app \
|
||||
golangci/golangci-lint:v1.39.0 \
|
||||
golangci-lint run -v --timeout 200s
|
||||
workingDirectory: 'outpost/'
|
||||
- stage: build_docker
|
||||
jobs:
|
||||
- job: proxy_build_docker
|
||||
@ -73,7 +69,7 @@ stages:
|
||||
containerRegistry: 'beryjuorg-harbor'
|
||||
repository: 'authentik/outpost-proxy'
|
||||
command: 'build'
|
||||
Dockerfile: 'outpost/proxy.Dockerfile'
|
||||
Dockerfile: 'proxy.Dockerfile'
|
||||
buildContext: '$(Build.SourcesDirectory)'
|
||||
tags: |
|
||||
gh-$(branchName)
|
||||
@ -106,7 +102,7 @@ stages:
|
||||
containerRegistry: 'beryjuorg-harbor'
|
||||
repository: 'authentik/outpost-ldap'
|
||||
command: 'build'
|
||||
Dockerfile: 'outpost/ldap.Dockerfile'
|
||||
Dockerfile: 'ldap.Dockerfile'
|
||||
buildContext: '$(Build.SourcesDirectory)'
|
||||
tags: |
|
||||
gh-$(branchName)
|
@ -14,13 +14,13 @@ resources:
|
||||
- repo: self
|
||||
|
||||
variables:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||
${{ if startsWith(variables['Build.SourceBranch'], 'refs/pull/') }}:
|
||||
branchName: ${{ replace(variables['System.PullRequest.SourceBranch'], '/', '-') }}
|
||||
${{ if startsWith(variables['Build.SourceBranch'], 'refs/heads/') }}:
|
||||
branchName: ${{ replace(variables['Build.SourceBranchName'], 'refs/heads/', '') }}
|
||||
- name: POSTGRES_DB
|
||||
value: authentik
|
||||
- name: POSTGRES_USER
|
||||
value: authentik
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: "EK-5jnKfjrGRm<77"
|
||||
- group: coverage
|
||||
|
||||
stages:
|
||||
- stage: Lint_and_test
|
||||
@ -397,6 +397,16 @@ stages:
|
||||
- task: CmdLine@2
|
||||
inputs:
|
||||
script: bash <(curl -s https://codecov.io/bash)
|
||||
- task: CmdLine@2
|
||||
inputs:
|
||||
script: |
|
||||
npm install -g @zeus-ci/cli
|
||||
npx zeus job update -b $BUILD_BUILDID -j $BUILD_BUILDNUMBER -r $BUILD_SOURCEVERSION
|
||||
npx zeus upload -b $BUILD_BUILDID -j $BUILD_BUILDNUMBER -t "application/x-cobertura+xml" coverage.xml
|
||||
npx zeus upload -b $BUILD_BUILDID -j $BUILD_BUILDNUMBER -t "application/x-junit+xml" coverage-e2e/unittest.xml
|
||||
npx zeus upload -b $BUILD_BUILDID -j $BUILD_BUILDNUMBER -t "application/x-junit+xml" coverage-integration/unittest.xml
|
||||
npx zeus upload -b $BUILD_BUILDID -j $BUILD_BUILDNUMBER -t "application/x-junit+xml" coverage-unittest/unittest.xml
|
||||
npx zeus job update --status=passed -b $BUILD_BUILDID -j $BUILD_BUILDNUMBER -r $BUILD_SOURCEVERSION
|
||||
- stage: Build
|
||||
jobs:
|
||||
- job: build_server
|
||||
|
@ -2,16 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"goauthentik.io/outpost/pkg/ak"
|
||||
"goauthentik.io/outpost/pkg/ldap"
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/ldap"
|
||||
)
|
||||
|
||||
const helpMessage = `authentik ldap
|
||||
@ -23,32 +21,30 @@ Required environment variables:
|
||||
|
||||
func main() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
pbURL, found := os.LookupEnv("AUTHENTIK_HOST")
|
||||
akURL, found := os.LookupEnv("AUTHENTIK_HOST")
|
||||
if !found {
|
||||
fmt.Println("env AUTHENTIK_HOST not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
pbToken, found := os.LookupEnv("AUTHENTIK_TOKEN")
|
||||
akToken, found := os.LookupEnv("AUTHENTIK_TOKEN")
|
||||
if !found {
|
||||
fmt.Println("env AUTHENTIK_TOKEN not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pbURLActual, err := url.Parse(pbURL)
|
||||
akURLActual, err := url.Parse(akURL)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
|
||||
ac := ak.NewAPIController(*pbURLActual, pbToken)
|
||||
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
ac := ak.NewAPIController(*akURLActual, akToken)
|
||||
|
||||
ac.Server = ldap.NewServer(ac)
|
||||
|
||||
@ -58,7 +54,7 @@ func main() {
|
||||
}
|
||||
|
||||
for {
|
||||
<-interrupt
|
||||
<-ex
|
||||
ac.Shutdown()
|
||||
os.Exit(0)
|
||||
}
|
@ -2,16 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"goauthentik.io/outpost/pkg/ak"
|
||||
"goauthentik.io/outpost/pkg/proxy"
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/proxy"
|
||||
)
|
||||
|
||||
const helpMessage = `authentik proxy
|
||||
@ -23,32 +21,30 @@ Required environment variables:
|
||||
|
||||
func main() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
pbURL, found := os.LookupEnv("AUTHENTIK_HOST")
|
||||
akURL, found := os.LookupEnv("AUTHENTIK_HOST")
|
||||
if !found {
|
||||
fmt.Println("env AUTHENTIK_HOST not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
pbToken, found := os.LookupEnv("AUTHENTIK_TOKEN")
|
||||
akToken, found := os.LookupEnv("AUTHENTIK_TOKEN")
|
||||
if !found {
|
||||
fmt.Println("env AUTHENTIK_TOKEN not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pbURLActual, err := url.Parse(pbURL)
|
||||
akURLActual, err := url.Parse(akURL)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
|
||||
ac := ak.NewAPIController(*pbURLActual, pbToken)
|
||||
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
ac := ak.NewAPIController(*akURLActual, akToken)
|
||||
|
||||
ac.Server = proxy.NewServer(ac)
|
||||
|
||||
@ -58,7 +54,7 @@ func main() {
|
||||
}
|
||||
|
||||
for {
|
||||
<-interrupt
|
||||
<-ex
|
||||
ac.Shutdown()
|
||||
os.Exit(0)
|
||||
}
|
@ -2,51 +2,106 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/constants"
|
||||
"goauthentik.io/internal/gounicorn"
|
||||
"goauthentik.io/internal/web"
|
||||
)
|
||||
|
||||
var running = true
|
||||
|
||||
func main() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
config.DefaultConfig()
|
||||
config.LoadConfig("./authentik/lib/default.yml")
|
||||
config.LoadConfig("./local.env.yml")
|
||||
err := config.LoadConfig("./authentik/lib/default.yml")
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to load default config")
|
||||
}
|
||||
err = config.LoadConfig("./local.env.yml")
|
||||
if err != nil {
|
||||
log.WithError(err).Debug("failed to local config")
|
||||
}
|
||||
config.ConfigureLogger()
|
||||
|
||||
if config.G.ErrorReporting.Enabled {
|
||||
sentry.Init(sentry.ClientOptions{
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8",
|
||||
AttachStacktrace: true,
|
||||
TracesSampleRate: 0.6,
|
||||
Release: fmt.Sprintf("authentik@%s", constants.VERSION),
|
||||
Environment: config.G.ErrorReporting.Environment,
|
||||
})
|
||||
defer sentry.Flush(time.Second * 5)
|
||||
defer sentry.Recover()
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to init sentry")
|
||||
}
|
||||
}
|
||||
|
||||
rl := log.WithField("logger", "authentik.g")
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
g := gounicorn.NewGoUnicorn()
|
||||
for {
|
||||
err := g.Start()
|
||||
rl.WithError(err).Warning("gunicorn process died, restarting")
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ws := web.NewWebServer()
|
||||
ws.Run()
|
||||
}()
|
||||
wg.Wait()
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
|
||||
// u, _ := url.Parse("http://localhost:8000")
|
||||
|
||||
g := gounicorn.NewGoUnicorn()
|
||||
ws := web.NewWebServer()
|
||||
defer g.Kill()
|
||||
defer ws.Shutdown()
|
||||
for {
|
||||
go attemptStartBackend(g)
|
||||
ws.Start()
|
||||
// go attemptProxyStart(u)
|
||||
|
||||
<-ex
|
||||
running = false
|
||||
log.WithField("logger", "authentik").Info("shutting down webserver")
|
||||
go ws.Shutdown()
|
||||
log.WithField("logger", "authentik").Info("killing gunicorn")
|
||||
g.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func attemptStartBackend(g *gounicorn.GoUnicorn) {
|
||||
for {
|
||||
err := g.Start()
|
||||
if !running {
|
||||
return
|
||||
}
|
||||
log.WithField("logger", "authentik.g").WithError(err).Warning("gunicorn process died, restarting")
|
||||
}
|
||||
}
|
||||
|
||||
// func attemptProxyStart(u *url.URL) error {
|
||||
// maxTries := 100
|
||||
// attempt := 0
|
||||
// for {
|
||||
// log.WithField("logger", "authentik").Debug("attempting to init outpost")
|
||||
// ac := ak.NewAPIController(*u, config.G.SecretKey)
|
||||
// if ac == nil {
|
||||
// attempt += 1
|
||||
// time.Sleep(1 * time.Second)
|
||||
// if attempt > maxTries {
|
||||
// break
|
||||
// }
|
||||
// continue
|
||||
// }
|
||||
// ac.Server = proxy.NewServer(ac)
|
||||
// err := ac.Start()
|
||||
// log.WithField("logger", "authentik").Debug("attempting to start outpost")
|
||||
// if err != nil {
|
||||
// attempt += 1
|
||||
// time.Sleep(5 * time.Second)
|
||||
// if attempt > maxTries {
|
||||
// break
|
||||
// }
|
||||
// continue
|
||||
// }
|
||||
// if !running {
|
||||
// ac.Shutdown()
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
|
@ -21,7 +21,7 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2021.6.4}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2021.7.1}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -44,7 +44,7 @@ services:
|
||||
- "0.0.0.0:9000:9000"
|
||||
- "0.0.0.0:9443:9443"
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2021.6.4}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2021.7.1}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
networks:
|
||||
|
50
go.mod
50
go.mod
@ -3,11 +3,49 @@ module goauthentik.io
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/getsentry/sentry-go v0.10.0 // indirect
|
||||
github.com/gorilla/handlers v1.5.1 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/imdario/mergo v0.3.12 // indirect
|
||||
github.com/pkg/errors v0.8.1 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
|
||||
github.com/coreos/go-oidc v2.2.1+incompatible
|
||||
github.com/getsentry/sentry-go v0.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.3.0
|
||||
github.com/go-openapi/analysis v0.20.1 // indirect
|
||||
github.com/go-openapi/errors v0.20.0 // indirect
|
||||
github.com/go-openapi/runtime v0.19.29
|
||||
github.com/go-openapi/strfmt v0.20.1
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-openapi/validate v0.20.2 // indirect
|
||||
github.com/go-redis/redis/v7 v7.4.0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/imdario/mergo v0.3.12
|
||||
github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a
|
||||
github.com/justinas/alice v1.2.0
|
||||
github.com/kr/pretty v0.2.1 // indirect
|
||||
github.com/magiconair/properties v1.8.5 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 // indirect
|
||||
github.com/nmcclain/ldap v0.0.0-20191021200707-3b3b69a7e9e3
|
||||
github.com/oauth2-proxy/oauth2-proxy v0.0.0-20200831161845-e4e5580852dc
|
||||
github.com/pelletier/go-toml v1.9.1 // indirect
|
||||
github.com/pires/go-proxyproto v0.6.0 // indirect
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pquerna/cachecontrol v0.0.0-20201205024021-ac21108117ac // indirect
|
||||
github.com/recws-org/recws v1.3.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
gopkg.in/yaml.v2 v2.3.0 // indirect
|
||||
github.com/spf13/afero v1.6.0 // indirect
|
||||
github.com/spf13/cast v1.3.1 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.7.1 // indirect
|
||||
go.mongodb.org/mongo-driver v1.5.2 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect
|
||||
golang.org/x/net v0.0.0-20210510120150-4163338589ed // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20210323180902-22b0adad7558 // indirect
|
||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
gopkg.in/ini.v1 v1.62.0 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.5.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
22
internal/common/global.go
Normal file
22
internal/common/global.go
Normal file
@ -0,0 +1,22 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
)
|
||||
|
||||
func Init() chan os.Signal {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
return interrupt
|
||||
}
|
||||
|
||||
func Defer() {
|
||||
defer sentry.Flush(time.Second * 5)
|
||||
defer sentry.Recover()
|
||||
}
|
@ -2,6 +2,7 @@ package config
|
||||
|
||||
type Config struct {
|
||||
Debug bool `yaml:"debug"`
|
||||
SecretKey string `yaml:"secret_key"`
|
||||
Web WebConfig `yaml:"web"`
|
||||
Paths PathsConfig `yaml:"paths"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
|
@ -1,3 +1,20 @@
|
||||
package constants
|
||||
|
||||
const VERSION = "2021.6.4"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func BUILD() string {
|
||||
build := os.Getenv("GIT_BUILD_HASH")
|
||||
if build == "" {
|
||||
return "tagged"
|
||||
}
|
||||
return build
|
||||
}
|
||||
|
||||
func OutpostUserAgent() string {
|
||||
return fmt.Sprintf("authentik-outpost@%s (%s)", VERSION, BUILD())
|
||||
}
|
||||
|
||||
const VERSION = "2021.7.1"
|
||||
|
@ -9,16 +9,24 @@ import (
|
||||
)
|
||||
|
||||
type GoUnicorn struct {
|
||||
log *log.Entry
|
||||
log *log.Entry
|
||||
p *exec.Cmd
|
||||
started bool
|
||||
killed bool
|
||||
}
|
||||
|
||||
func NewGoUnicorn() *GoUnicorn {
|
||||
return &GoUnicorn{
|
||||
log: log.WithField("logger", "authentik.g.unicorn"),
|
||||
logger := log.WithField("logger", "authentik.g.unicorn")
|
||||
g := &GoUnicorn{
|
||||
log: logger,
|
||||
started: false,
|
||||
killed: false,
|
||||
}
|
||||
g.initCmd()
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *GoUnicorn) Start() error {
|
||||
func (g *GoUnicorn) initCmd() {
|
||||
command := "gunicorn"
|
||||
args := []string{"-c", "./lifecycle/gunicorn.conf.py", "authentik.root.asgi:application"}
|
||||
if config.G.Debug {
|
||||
@ -26,11 +34,28 @@ func (g *GoUnicorn) Start() error {
|
||||
args = []string{"manage.py", "runserver", "localhost:8000"}
|
||||
}
|
||||
g.log.WithField("args", args).WithField("cmd", command).Debug("Starting gunicorn")
|
||||
p := exec.Command(command, args...)
|
||||
p.Env = append(os.Environ(),
|
||||
"WORKERS=2",
|
||||
)
|
||||
p.Stdout = os.Stdout
|
||||
p.Stderr = os.Stderr
|
||||
return p.Run()
|
||||
g.p = exec.Command(command, args...)
|
||||
g.p.Env = os.Environ()
|
||||
g.p.Stdout = os.Stdout
|
||||
g.p.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
func (g *GoUnicorn) Start() error {
|
||||
if g.killed {
|
||||
g.log.Debug("Not restarting gunicorn since we're killed")
|
||||
return nil
|
||||
}
|
||||
if g.started {
|
||||
g.initCmd()
|
||||
}
|
||||
g.started = true
|
||||
return g.p.Run()
|
||||
}
|
||||
|
||||
func (g *GoUnicorn) Kill() {
|
||||
g.killed = true
|
||||
err := g.p.Process.Kill()
|
||||
if err != nil {
|
||||
g.log.WithError(err).Warning("failed to kill gunicorn")
|
||||
}
|
||||
}
|
||||
|
@ -6,15 +6,14 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/recws-org/recws"
|
||||
"goauthentik.io/outpost/api"
|
||||
"goauthentik.io/outpost/pkg"
|
||||
"goauthentik.io/api"
|
||||
"goauthentik.io/internal/constants"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@ -41,10 +40,11 @@ type APIController struct {
|
||||
// NewAPIController initialise new API Controller instance from URL and API token
|
||||
func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
config := api.NewConfiguration()
|
||||
config.UserAgent = constants.OutpostUserAgent()
|
||||
config.Host = akURL.Host
|
||||
config.Scheme = akURL.Scheme
|
||||
config.HTTPClient = &http.Client{
|
||||
Transport: SetUserAgent(GetTLSTransport(), pkg.UserAgent()),
|
||||
Transport: NewTracingTransport(GetTLSTransport()),
|
||||
}
|
||||
config.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
@ -59,7 +59,7 @@ func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to fetch configuration")
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}
|
||||
outpost := outposts.Results[0]
|
||||
doGlobalSetup(outpost.Config)
|
@ -3,7 +3,7 @@ package ak
|
||||
import (
|
||||
"context"
|
||||
|
||||
"goauthentik.io/outpost/api"
|
||||
"goauthentik.io/api"
|
||||
)
|
||||
|
||||
func (a *APIController) Update() ([]api.ProxyOutpostConfig, error) {
|
@ -12,7 +12,7 @@ import (
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/recws-org/recws"
|
||||
"goauthentik.io/outpost/pkg"
|
||||
"goauthentik.io/internal/constants"
|
||||
)
|
||||
|
||||
func (ac *APIController) initWS(akURL url.URL, outpostUUID strfmt.UUID) {
|
||||
@ -23,7 +23,7 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID strfmt.UUID) {
|
||||
|
||||
header := http.Header{
|
||||
"Authorization": []string{authHeader},
|
||||
"User-Agent": []string{pkg.UserAgent()},
|
||||
"User-Agent": []string{constants.OutpostUserAgent()},
|
||||
}
|
||||
|
||||
value, set := os.LookupEnv("AUTHENTIK_INSECURE")
|
||||
@ -46,8 +46,8 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID strfmt.UUID) {
|
||||
msg := websocketMessage{
|
||||
Instruction: WebsocketInstructionHello,
|
||||
Args: map[string]interface{}{
|
||||
"version": pkg.VERSION,
|
||||
"buildHash": pkg.BUILD(),
|
||||
"version": constants.VERSION,
|
||||
"buildHash": constants.BUILD(),
|
||||
"uuid": ac.instanceUUID.String(),
|
||||
},
|
||||
}
|
||||
@ -71,14 +71,12 @@ func (ac *APIController) Shutdown() {
|
||||
func (ac *APIController) startWSHandler() {
|
||||
logger := ac.logger.WithField("loop", "ws-handler")
|
||||
for {
|
||||
if !ac.wsConn.IsConnected() {
|
||||
continue
|
||||
}
|
||||
var wsMsg websocketMessage
|
||||
err := ac.wsConn.ReadJSON(&wsMsg)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warning("ws write error, reconnecting")
|
||||
ac.wsConn.CloseAndReconnect()
|
||||
time.Sleep(time.Second * 5)
|
||||
continue
|
||||
}
|
||||
if wsMsg.Instruction == WebsocketInstructionTriggerUpdate {
|
||||
@ -101,8 +99,8 @@ func (ac *APIController) startWSHealth() {
|
||||
aliveMsg := websocketMessage{
|
||||
Instruction: WebsocketInstructionHello,
|
||||
Args: map[string]interface{}{
|
||||
"version": pkg.VERSION,
|
||||
"buildHash": pkg.BUILD(),
|
||||
"version": constants.VERSION,
|
||||
"buildHash": constants.BUILD(),
|
||||
"uuid": ac.instanceUUID.String(),
|
||||
},
|
||||
}
|
85
internal/outpost/ak/crypto.go
Normal file
85
internal/outpost/ak/crypto.go
Normal file
@ -0,0 +1,85 @@
|
||||
package ak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"goauthentik.io/api"
|
||||
)
|
||||
|
||||
type CryptoStore struct {
|
||||
api *api.CryptoApiService
|
||||
|
||||
log *log.Entry
|
||||
|
||||
fingerprints map[string]string
|
||||
certificates map[string]*tls.Certificate
|
||||
}
|
||||
|
||||
func NewCryptoStore(cryptoApi *api.CryptoApiService) *CryptoStore {
|
||||
return &CryptoStore{
|
||||
api: cryptoApi,
|
||||
log: log.WithField("logger", "authentik.outpost.cryptostore"),
|
||||
fingerprints: make(map[string]string),
|
||||
certificates: make(map[string]*tls.Certificate),
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CryptoStore) AddKeypair(uuid string) error {
|
||||
// If they keypair was already added, don't
|
||||
// do it again
|
||||
if _, ok := cs.fingerprints[uuid]; ok {
|
||||
return nil
|
||||
}
|
||||
// reset fingerprint to force update
|
||||
cs.fingerprints[uuid] = ""
|
||||
err := cs.Fetch(uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs.fingerprints[uuid] = cs.getFingerprint(uuid)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cs *CryptoStore) getFingerprint(uuid string) string {
|
||||
kp, _, err := cs.api.CryptoCertificatekeypairsRetrieve(context.Background(), uuid).Execute()
|
||||
if err != nil {
|
||||
cs.log.WithField("uuid", uuid).WithError(err).Warning("Failed to fetch certificate's fingerprint")
|
||||
return ""
|
||||
}
|
||||
return kp.FingerprintSha256
|
||||
}
|
||||
|
||||
func (cs *CryptoStore) Fetch(uuid string) error {
|
||||
cfp := cs.getFingerprint(uuid)
|
||||
if cfp == cs.fingerprints[uuid] {
|
||||
cs.log.WithField("uuid", uuid).Info("Fingerprint hasn't changed, not fetching cert")
|
||||
return nil
|
||||
}
|
||||
cs.log.WithField("uuid", uuid).Info("Fetching certificate and private key")
|
||||
|
||||
cert, _, err := cs.api.CryptoCertificatekeypairsViewCertificateRetrieve(context.Background(), uuid).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, _, err := cs.api.CryptoCertificatekeypairsViewPrivateKeyRetrieve(context.Background(), uuid).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
x509cert, err := tls.X509KeyPair([]byte(cert.Data), []byte(key.Data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs.certificates[uuid] = &x509cert
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cs *CryptoStore) Get(uuid string) *tls.Certificate {
|
||||
err := cs.Fetch(uuid)
|
||||
if err != nil {
|
||||
cs.log.WithError(err).Warning("failed to fetch certificate")
|
||||
}
|
||||
return cs.certificates[uuid]
|
||||
}
|
@ -9,7 +9,7 @@ import (
|
||||
"github.com/getsentry/sentry-go"
|
||||
httptransport "github.com/go-openapi/runtime/client"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"goauthentik.io/outpost/pkg"
|
||||
"goauthentik.io/internal/constants"
|
||||
)
|
||||
|
||||
func doGlobalSetup(config map[string]interface{}) {
|
||||
@ -33,17 +33,19 @@ func doGlobalSetup(config map[string]interface{}) {
|
||||
default:
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
log.WithField("buildHash", pkg.BUILD()).WithField("version", pkg.VERSION).Info("Starting authentik outpost")
|
||||
log.WithField("buildHash", constants.BUILD()).WithField("version", constants.VERSION).Info("Starting authentik outpost")
|
||||
|
||||
env := config[ConfigErrorReportingEnvironment].(string)
|
||||
var dsn string
|
||||
if config[ConfigErrorReportingEnabled].(bool) {
|
||||
dsn = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8"
|
||||
log.Debug("Error reporting enabled")
|
||||
log.WithField("env", env).Debug("Error reporting enabled")
|
||||
}
|
||||
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: dsn,
|
||||
Environment: config[ConfigErrorReportingEnvironment].(string),
|
||||
Dsn: dsn,
|
||||
Environment: env,
|
||||
TracesSampleRate: 1,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("sentry.Init: %s", err)
|
23
internal/outpost/ak/tracing.go
Normal file
23
internal/outpost/ak/tracing.go
Normal file
@ -0,0 +1,23 @@
|
||||
package ak
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
)
|
||||
|
||||
type tracingTransport struct {
|
||||
inner http.RoundTripper
|
||||
}
|
||||
|
||||
func NewTracingTransport(inner http.RoundTripper) *tracingTransport {
|
||||
return &tracingTransport{inner}
|
||||
}
|
||||
|
||||
func (tt *tracingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
span := sentry.StartSpan(r.Context(), "authentik.go.http_request")
|
||||
span.SetTag("url", r.URL.String())
|
||||
span.SetTag("method", r.Method)
|
||||
defer span.Finish()
|
||||
return tt.inner.RoundTrip(r.WithContext(span.Context()))
|
||||
}
|
202
internal/outpost/flow.go
Normal file
202
internal/outpost/flow.go
Normal file
@ -0,0 +1,202 @@
|
||||
package outpost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"goauthentik.io/api"
|
||||
"goauthentik.io/internal/constants"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
)
|
||||
|
||||
type StageComponent string
|
||||
|
||||
const (
|
||||
StageIdentification = StageComponent("ak-stage-identification")
|
||||
StagePassword = StageComponent("ak-stage-password")
|
||||
StageAuthenticatorValidate = StageComponent("ak-stage-authenticator-validate")
|
||||
StageAccessDenied = StageComponent("ak-stage-access-denied")
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderAuthentikRemoteIP = "X-authentik-remote-ip"
|
||||
HeaderAuthentikOutpostToken = "X-authentik-outpost-token"
|
||||
)
|
||||
|
||||
type FlowExecutor struct {
|
||||
Params url.Values
|
||||
Answers map[StageComponent]string
|
||||
Context context.Context
|
||||
|
||||
api *api.APIClient
|
||||
flowSlug string
|
||||
log *log.Entry
|
||||
token string
|
||||
|
||||
sp *sentry.Span
|
||||
}
|
||||
|
||||
func NewFlowExecutor(ctx context.Context, flowSlug string, refConfig *api.Configuration, logFields log.Fields) *FlowExecutor {
|
||||
rsp := sentry.StartSpan(ctx, "authentik.outposts.flow_executor")
|
||||
|
||||
l := log.WithField("flow", flowSlug).WithFields(logFields)
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("Failed to create cookiejar")
|
||||
panic(err)
|
||||
}
|
||||
// Create new http client that also sets the correct ip
|
||||
config := api.NewConfiguration()
|
||||
config.Host = refConfig.Host
|
||||
config.Scheme = refConfig.Scheme
|
||||
config.UserAgent = constants.OutpostUserAgent()
|
||||
config.HTTPClient = &http.Client{
|
||||
Jar: jar,
|
||||
Transport: ak.NewTracingTransport(ak.GetTLSTransport()),
|
||||
}
|
||||
apiClient := api.NewAPIClient(config)
|
||||
return &FlowExecutor{
|
||||
Params: url.Values{},
|
||||
Answers: make(map[StageComponent]string),
|
||||
Context: rsp.Context(),
|
||||
api: apiClient,
|
||||
flowSlug: flowSlug,
|
||||
log: l,
|
||||
token: strings.Split(refConfig.DefaultHeader["Authorization"], " ")[1],
|
||||
sp: rsp,
|
||||
}
|
||||
}
|
||||
|
||||
func (fe *FlowExecutor) ApiClient() *api.APIClient {
|
||||
return fe.api
|
||||
}
|
||||
|
||||
type ChallengeInt interface {
|
||||
GetComponent() string
|
||||
GetType() api.ChallengeChoices
|
||||
GetResponseErrors() map[string][]api.ErrorDetail
|
||||
}
|
||||
|
||||
func (fe *FlowExecutor) DelegateClientIP(a net.Addr) {
|
||||
host, _, err := net.SplitHostPort(a.String())
|
||||
if err != nil {
|
||||
fe.log.WithError(err).Warning("Failed to get remote IP")
|
||||
return
|
||||
}
|
||||
fe.api.GetConfig().AddDefaultHeader(HeaderAuthentikRemoteIP, host)
|
||||
fe.api.GetConfig().AddDefaultHeader(HeaderAuthentikOutpostToken, fe.token)
|
||||
}
|
||||
|
||||
func (fe *FlowExecutor) CheckApplicationAccess(appSlug string) (bool, error) {
|
||||
acsp := sentry.StartSpan(fe.Context, "authentik.outposts.flow_executor.check_access")
|
||||
defer acsp.Finish()
|
||||
p, _, err := fe.api.CoreApi.CoreApplicationsCheckAccessRetrieve(acsp.Context(), appSlug).Execute()
|
||||
if !p.Passing {
|
||||
fe.log.Info("Access denied for user")
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check access: %w", err)
|
||||
}
|
||||
fe.log.Debug("User has access")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (fe *FlowExecutor) getAnswer(stage StageComponent) string {
|
||||
if v, o := fe.Answers[stage]; o {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (fe *FlowExecutor) Execute() (bool, error) {
|
||||
return fe.solveFlowChallenge(1)
|
||||
}
|
||||
|
||||
func (fe *FlowExecutor) solveFlowChallenge(depth int) (bool, error) {
|
||||
defer fe.sp.Finish()
|
||||
|
||||
// Get challenge
|
||||
gcsp := sentry.StartSpan(fe.Context, "authentik.outposts.flow_executor.get_challenge")
|
||||
req := fe.api.FlowsApi.FlowsExecutorGet(gcsp.Context(), fe.flowSlug).Query(fe.Params.Encode())
|
||||
challenge, _, err := req.Execute()
|
||||
if err != nil {
|
||||
return false, errors.New("failed to get challenge")
|
||||
}
|
||||
ch := challenge.GetActualInstance().(ChallengeInt)
|
||||
fe.log.WithField("component", ch.GetComponent()).WithField("type", ch.GetType()).Debug("Got challenge")
|
||||
gcsp.SetTag("ak_challenge", string(ch.GetType()))
|
||||
gcsp.SetTag("ak_component", ch.GetComponent())
|
||||
gcsp.Finish()
|
||||
|
||||
// Resole challenge
|
||||
scsp := sentry.StartSpan(fe.Context, "authentik.outposts.flow_executor.solve_challenge")
|
||||
responseReq := fe.api.FlowsApi.FlowsExecutorSolve(scsp.Context(), fe.flowSlug).Query(fe.Params.Encode())
|
||||
switch ch.GetComponent() {
|
||||
case string(StageIdentification):
|
||||
responseReq = responseReq.FlowChallengeResponseRequest(api.IdentificationChallengeResponseRequestAsFlowChallengeResponseRequest(api.NewIdentificationChallengeResponseRequest(fe.getAnswer(StageIdentification))))
|
||||
case string(StagePassword):
|
||||
responseReq = responseReq.FlowChallengeResponseRequest(api.PasswordChallengeResponseRequestAsFlowChallengeResponseRequest(api.NewPasswordChallengeResponseRequest(fe.getAnswer(StagePassword))))
|
||||
case string(StageAuthenticatorValidate):
|
||||
// We only support duo as authenticator, check if that's allowed
|
||||
var deviceChallenge *api.DeviceChallenge
|
||||
for _, devCh := range challenge.AuthenticatorValidationChallenge.DeviceChallenges {
|
||||
if devCh.DeviceClass == string(api.DEVICECLASSESENUM_DUO) {
|
||||
deviceChallenge = &devCh
|
||||
}
|
||||
}
|
||||
if deviceChallenge == nil {
|
||||
return false, errors.New("got ak-stage-authenticator-validate without duo")
|
||||
}
|
||||
devId, err := strconv.Atoi(deviceChallenge.DeviceUid)
|
||||
if err != nil {
|
||||
return false, errors.New("failed to convert duo device id to int")
|
||||
}
|
||||
devId32 := int32(devId)
|
||||
inner := api.NewAuthenticatorValidationChallengeResponseRequest()
|
||||
inner.Duo = &devId32
|
||||
responseReq = responseReq.FlowChallengeResponseRequest(api.AuthenticatorValidationChallengeResponseRequestAsFlowChallengeResponseRequest(inner))
|
||||
case string(StageAccessDenied):
|
||||
return false, errors.New("got ak-stage-access-denied")
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported challenge type %s", ch.GetComponent())
|
||||
}
|
||||
|
||||
response, _, err := responseReq.Execute()
|
||||
ch = response.GetActualInstance().(ChallengeInt)
|
||||
fe.log.WithField("component", ch.GetComponent()).WithField("type", ch.GetType()).Debug("Got response")
|
||||
scsp.SetTag("ak_challenge", string(ch.GetType()))
|
||||
scsp.SetTag("ak_component", ch.GetComponent())
|
||||
scsp.Finish()
|
||||
|
||||
switch ch.GetComponent() {
|
||||
case string(StageAccessDenied):
|
||||
return false, errors.New("got ak-stage-access-denied")
|
||||
}
|
||||
if ch.GetType() == "redirect" {
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to submit challenge %w", err)
|
||||
}
|
||||
if len(ch.GetResponseErrors()) > 0 {
|
||||
for key, errs := range ch.GetResponseErrors() {
|
||||
for _, err := range errs {
|
||||
return false, fmt.Errorf("flow error %s: %s", key, err.String)
|
||||
}
|
||||
}
|
||||
}
|
||||
if depth >= 10 {
|
||||
return false, errors.New("exceeded stage recursion depth")
|
||||
}
|
||||
return fe.solveFlowChallenge(depth + 1)
|
||||
}
|
@ -2,13 +2,16 @@ package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/pires/go-proxyproto"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@ -24,6 +27,7 @@ func (ls *LDAPServer) Refresh() error {
|
||||
for idx, provider := range outposts.Results {
|
||||
userDN := strings.ToLower(fmt.Sprintf("ou=users,%s", *provider.BaseDn))
|
||||
groupDN := strings.ToLower(fmt.Sprintf("ou=groups,%s", *provider.BaseDn))
|
||||
logger := log.WithField("logger", "authentik.outpost.ldap").WithField("provider", provider.Name)
|
||||
providers[idx] = &ProviderInstance{
|
||||
BaseDN: *provider.BaseDn,
|
||||
GroupDN: groupDN,
|
||||
@ -34,7 +38,18 @@ func (ls *LDAPServer) Refresh() error {
|
||||
boundUsersMutex: sync.RWMutex{},
|
||||
boundUsers: make(map[string]UserFlags),
|
||||
s: ls,
|
||||
log: log.WithField("logger", "authentik.outpost.ldap").WithField("provider", provider.Name),
|
||||
log: logger,
|
||||
tlsServerName: provider.TlsServerName,
|
||||
uidStartNumber: *provider.UidStartNumber,
|
||||
gidStartNumber: *provider.GidStartNumber,
|
||||
}
|
||||
if provider.Certificate.Get() != nil {
|
||||
kp := provider.Certificate.Get()
|
||||
err := ls.cs.AddKeypair(*kp)
|
||||
if err != nil {
|
||||
ls.log.WithError(err).Warning("Failed to initially fetch certificate")
|
||||
}
|
||||
providers[idx].cert = ls.cs.Get(*kp)
|
||||
}
|
||||
}
|
||||
ls.providers = providers
|
||||
@ -54,13 +69,53 @@ func (ls *LDAPServer) StartHTTPServer() error {
|
||||
|
||||
func (ls *LDAPServer) StartLDAPServer() error {
|
||||
listen := "0.0.0.0:3389"
|
||||
|
||||
ln, err := net.Listen("tcp", listen)
|
||||
if err != nil {
|
||||
ls.log.Fatalf("FATAL: listen (%s) failed - %s", listen, err)
|
||||
}
|
||||
proxyListener := &proxyproto.Listener{Listener: ln}
|
||||
defer proxyListener.Close()
|
||||
|
||||
ls.log.WithField("listen", listen).Info("Starting ldap server")
|
||||
err = ls.s.Serve(proxyListener)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ls.log.Printf("closing %s", ln.Addr())
|
||||
return ls.s.ListenAndServe(listen)
|
||||
}
|
||||
|
||||
func (ls *LDAPServer) StartLDAPTLSServer() error {
|
||||
listen := "0.0.0.0:6636"
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
MaxVersion: tls.VersionTLS12,
|
||||
GetCertificate: ls.getCertificates,
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", listen)
|
||||
if err != nil {
|
||||
ls.log.Fatalf("FATAL: listen (%s) failed - %s", listen, err)
|
||||
}
|
||||
|
||||
proxyListener := &proxyproto.Listener{Listener: ln}
|
||||
defer proxyListener.Close()
|
||||
|
||||
tln := tls.NewListener(proxyListener, tlsConfig)
|
||||
|
||||
ls.log.WithField("listen", listen).Info("Starting ldap tls server")
|
||||
err = ls.s.Serve(tln)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ls.log.Printf("closing %s", ln.Addr())
|
||||
return ls.s.ListenAndServe(listen)
|
||||
}
|
||||
|
||||
func (ls *LDAPServer) Start() error {
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
wg.Add(3)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := ls.StartHTTPServer()
|
||||
@ -75,24 +130,13 @@ func (ls *LDAPServer) Start() error {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := ls.StartLDAPTLSServer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
type transport struct {
|
||||
headers map[string]string
|
||||
inner http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
for key, value := range t.headers {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
return t.inner.RoundTrip(req)
|
||||
}
|
||||
func newTransport(inner http.RoundTripper, headers map[string]string) *transport {
|
||||
return &transport{
|
||||
inner: inner,
|
||||
headers: headers,
|
||||
}
|
||||
}
|
23
internal/outpost/ldap/api_tls.go
Normal file
23
internal/outpost/ldap/api_tls.go
Normal file
@ -0,0 +1,23 @@
|
||||
package ldap
|
||||
|
||||
import "crypto/tls"
|
||||
|
||||
func (ls *LDAPServer) getCertificates(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if len(ls.providers) == 1 {
|
||||
if ls.providers[0].cert != nil {
|
||||
ls.log.WithField("server-name", info.ServerName).Debug("We only have a single provider, using their cert")
|
||||
return ls.providers[0].cert, nil
|
||||
}
|
||||
}
|
||||
for _, provider := range ls.providers {
|
||||
if provider.tlsServerName == &info.ServerName {
|
||||
if provider.cert == nil {
|
||||
ls.log.WithField("server-name", info.ServerName).Debug("Handler does not have a certificate")
|
||||
return ls.defaultCert, nil
|
||||
}
|
||||
return provider.cert, nil
|
||||
}
|
||||
}
|
||||
ls.log.WithField("server-name", info.ServerName).Debug("Fallback to default cert")
|
||||
return ls.defaultCert, nil
|
||||
}
|
50
internal/outpost/ldap/bind.go
Normal file
50
internal/outpost/ldap/bind.go
Normal file
@ -0,0 +1,50 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nmcclain/ldap"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type BindRequest struct {
|
||||
BindDN string
|
||||
BindPW string
|
||||
id string
|
||||
conn net.Conn
|
||||
log *log.Entry
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (ls *LDAPServer) Bind(bindDN string, bindPW string, conn net.Conn) (ldap.LDAPResultCode, error) {
|
||||
span := sentry.StartSpan(context.TODO(), "authentik.providers.ldap.bind",
|
||||
sentry.TransactionName("authentik.providers.ldap.bind"))
|
||||
span.SetTag("user.username", bindDN)
|
||||
defer span.Finish()
|
||||
|
||||
bindDN = strings.ToLower(bindDN)
|
||||
rid := uuid.New().String()
|
||||
req := BindRequest{
|
||||
BindDN: bindDN,
|
||||
BindPW: bindPW,
|
||||
conn: conn,
|
||||
log: ls.log.WithField("bindDN", bindDN).WithField("requestId", rid).WithField("client", conn.RemoteAddr().String()),
|
||||
id: rid,
|
||||
ctx: span.Context(),
|
||||
}
|
||||
req.log.Info("Bind request")
|
||||
for _, instance := range ls.providers {
|
||||
username, err := instance.getUsername(bindDN)
|
||||
if err == nil {
|
||||
return instance.Bind(username, req)
|
||||
} else {
|
||||
ls.log.WithError(err).Debug("Username not for instance")
|
||||
}
|
||||
}
|
||||
req.log.WithField("request", "bind").Warning("No provider found for request")
|
||||
return ldap.LDAPResultOperationsError, nil
|
||||
}
|
120
internal/outpost/ldap/instance_bind.go
Normal file
120
internal/outpost/ldap/instance_bind.go
Normal file
@ -0,0 +1,120 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
goldap "github.com/go-ldap/ldap/v3"
|
||||
"github.com/nmcclain/ldap"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"goauthentik.io/api"
|
||||
"goauthentik.io/internal/outpost"
|
||||
)
|
||||
|
||||
const ContextUserKey = "ak_user"
|
||||
|
||||
func (pi *ProviderInstance) getUsername(dn string) (string, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(dn), strings.ToLower(pi.BaseDN)) {
|
||||
return "", errors.New("invalid base DN")
|
||||
}
|
||||
dns, err := goldap.ParseDN(dn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, part := range dns.RDNs {
|
||||
for _, attribute := range part.Attributes {
|
||||
if strings.ToLower(attribute.Type) == "cn" {
|
||||
return attribute.Value, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("failed to find cn")
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) Bind(username string, req BindRequest) (ldap.LDAPResultCode, error) {
|
||||
fe := outpost.NewFlowExecutor(req.ctx, pi.flowSlug, pi.s.ac.Client.GetConfig(), log.Fields{
|
||||
"bindDN": req.BindDN,
|
||||
"client": req.conn.RemoteAddr().String(),
|
||||
"requestId": req.id,
|
||||
})
|
||||
fe.DelegateClientIP(req.conn.RemoteAddr())
|
||||
fe.Params.Add("goauthentik.io/outpost/ldap", "true")
|
||||
|
||||
fe.Answers[outpost.StageIdentification] = username
|
||||
fe.Answers[outpost.StagePassword] = req.BindPW
|
||||
|
||||
passed, err := fe.Execute()
|
||||
if !passed {
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
if err != nil {
|
||||
req.log.WithError(err).Warning("failed to execute flow")
|
||||
return ldap.LDAPResultOperationsError, nil
|
||||
}
|
||||
|
||||
access, err := fe.CheckApplicationAccess(pi.appSlug)
|
||||
if !access {
|
||||
req.log.Info("Access denied for user")
|
||||
return ldap.LDAPResultInsufficientAccessRights, nil
|
||||
}
|
||||
if err != nil {
|
||||
req.log.WithError(err).Warning("failed to check access")
|
||||
return ldap.LDAPResultOperationsError, nil
|
||||
}
|
||||
req.log.Info("User has access")
|
||||
uisp := sentry.StartSpan(req.ctx, "authentik.providers.ldap.bind.user_info")
|
||||
// Get user info to store in context
|
||||
userInfo, _, err := fe.ApiClient().CoreApi.CoreUsersMeRetrieve(context.Background()).Execute()
|
||||
if err != nil {
|
||||
req.log.WithError(err).Warning("failed to get user info")
|
||||
return ldap.LDAPResultOperationsError, nil
|
||||
}
|
||||
pi.boundUsersMutex.Lock()
|
||||
cs := pi.SearchAccessCheck(userInfo.User)
|
||||
pi.boundUsers[req.BindDN] = UserFlags{
|
||||
UserInfo: userInfo.User,
|
||||
CanSearch: cs != nil,
|
||||
}
|
||||
if pi.boundUsers[req.BindDN].CanSearch {
|
||||
req.log.WithField("group", cs).Info("Allowed access to search")
|
||||
}
|
||||
uisp.Finish()
|
||||
defer pi.boundUsersMutex.Unlock()
|
||||
pi.delayDeleteUserInfo(username)
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
}
|
||||
|
||||
// SearchAccessCheck Check if the current user is allowed to search
|
||||
func (pi *ProviderInstance) SearchAccessCheck(user api.User) *string {
|
||||
for _, group := range user.Groups {
|
||||
for _, allowedGroup := range pi.searchAllowedGroups {
|
||||
pi.log.WithField("userGroup", group.Pk).WithField("allowedGroup", allowedGroup).Trace("Checking search access")
|
||||
if group.Pk == allowedGroup.String() {
|
||||
return &group.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) delayDeleteUserInfo(dn string) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
quit := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
pi.boundUsersMutex.Lock()
|
||||
delete(pi.boundUsers, dn)
|
||||
pi.boundUsersMutex.Unlock()
|
||||
close(quit)
|
||||
case <-quit:
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
199
internal/outpost/ldap/instance_search.go
Normal file
199
internal/outpost/ldap/instance_search.go
Normal file
@ -0,0 +1,199 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/nmcclain/ldap"
|
||||
"goauthentik.io/api"
|
||||
)
|
||||
|
||||
func (pi *ProviderInstance) SearchMe(user api.User) (ldap.ServerSearchResult, error) {
|
||||
entries := make([]*ldap.Entry, 1)
|
||||
entries[0] = pi.UserEntry(user)
|
||||
return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) Search(req SearchRequest) (ldap.ServerSearchResult, error) {
|
||||
accsp := sentry.StartSpan(req.ctx, "authentik.providers.ldap.search.check_access")
|
||||
baseDN := strings.ToLower("," + pi.BaseDN)
|
||||
|
||||
entries := []*ldap.Entry{}
|
||||
filterEntity, err := ldap.GetFilterObjectClass(req.Filter)
|
||||
if err != nil {
|
||||
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: error parsing filter: %s", req.Filter)
|
||||
}
|
||||
if len(req.BindDN) < 1 {
|
||||
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", req.BindDN)
|
||||
}
|
||||
if !strings.HasSuffix(req.BindDN, baseDN) {
|
||||
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: BindDN %s not in our BaseDN %s", req.BindDN, pi.BaseDN)
|
||||
}
|
||||
|
||||
pi.boundUsersMutex.RLock()
|
||||
flags, ok := pi.boundUsers[req.BindDN]
|
||||
pi.boundUsersMutex.RUnlock()
|
||||
if !ok {
|
||||
pi.log.Debug("User info not cached")
|
||||
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, errors.New("access denied")
|
||||
}
|
||||
if !flags.CanSearch {
|
||||
pi.log.Debug("User can't search, showing info about user")
|
||||
return pi.SearchMe(flags.UserInfo)
|
||||
}
|
||||
accsp.Finish()
|
||||
|
||||
parsedFilter, err := ldap.CompileFilter(req.Filter)
|
||||
if err != nil {
|
||||
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: error parsing filter: %s", req.Filter)
|
||||
}
|
||||
|
||||
switch filterEntity {
|
||||
default:
|
||||
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: unhandled filter type: %s [%s]", filterEntity, req.Filter)
|
||||
case GroupObjectClass:
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
|
||||
gEntries := make([]*ldap.Entry, 0)
|
||||
uEntries := make([]*ldap.Entry, 0)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
gapisp := sentry.StartSpan(req.ctx, "authentik.providers.ldap.search.api_group")
|
||||
groups, _, err := parseFilterForGroup(pi.s.ac.Client.CoreApi.CoreGroupsList(gapisp.Context()), parsedFilter).Execute()
|
||||
gapisp.Finish()
|
||||
if err != nil {
|
||||
req.log.WithError(err).Warning("failed to get groups")
|
||||
return
|
||||
}
|
||||
pi.log.WithField("count", len(groups.Results)).Trace("Got results from API")
|
||||
|
||||
for _, g := range groups.Results {
|
||||
gEntries = append(gEntries, pi.GroupEntry(pi.APIGroupToLDAPGroup(g)))
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
uapisp := sentry.StartSpan(req.ctx, "authentik.providers.ldap.search.api_user")
|
||||
users, _, err := parseFilterForUser(pi.s.ac.Client.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter).Execute()
|
||||
uapisp.Finish()
|
||||
if err != nil {
|
||||
req.log.WithError(err).Warning("failed to get groups")
|
||||
return
|
||||
}
|
||||
|
||||
for _, u := range users.Results {
|
||||
uEntries = append(uEntries, pi.GroupEntry(pi.APIUserToLDAPGroup(u)))
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
entries = append(gEntries, uEntries...)
|
||||
case UserObjectClass, "":
|
||||
uapisp := sentry.StartSpan(req.ctx, "authentik.providers.ldap.search.api_user")
|
||||
users, _, err := parseFilterForUser(pi.s.ac.Client.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter).Execute()
|
||||
uapisp.Finish()
|
||||
|
||||
if err != nil {
|
||||
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("API Error: %s", err)
|
||||
}
|
||||
for _, u := range users.Results {
|
||||
entries = append(entries, pi.UserEntry(u))
|
||||
}
|
||||
}
|
||||
return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) UserEntry(u api.User) *ldap.Entry {
|
||||
attrs := []*ldap.EntryAttribute{
|
||||
{
|
||||
Name: "cn",
|
||||
Values: []string{u.Username},
|
||||
},
|
||||
{
|
||||
Name: "uid",
|
||||
Values: []string{u.Uid},
|
||||
},
|
||||
{
|
||||
Name: "name",
|
||||
Values: []string{u.Name},
|
||||
},
|
||||
{
|
||||
Name: "displayName",
|
||||
Values: []string{u.Name},
|
||||
},
|
||||
{
|
||||
Name: "mail",
|
||||
Values: []string{*u.Email},
|
||||
},
|
||||
{
|
||||
Name: "objectClass",
|
||||
Values: []string{UserObjectClass, "organizationalPerson", "goauthentik.io/ldap/user"},
|
||||
},
|
||||
{
|
||||
Name: "uidNumber",
|
||||
Values: []string{pi.GetUidNumber(u)},
|
||||
},
|
||||
{
|
||||
Name: "gidNumber",
|
||||
Values: []string{pi.GetUidNumber(u)},
|
||||
},
|
||||
}
|
||||
|
||||
attrs = append(attrs, &ldap.EntryAttribute{Name: "memberOf", Values: pi.GroupsForUser(u)})
|
||||
|
||||
// Old fields for backwards compatibility
|
||||
attrs = append(attrs, &ldap.EntryAttribute{Name: "accountStatus", Values: []string{BoolToString(*u.IsActive)}})
|
||||
attrs = append(attrs, &ldap.EntryAttribute{Name: "superuser", Values: []string{BoolToString(u.IsSuperuser)}})
|
||||
|
||||
attrs = append(attrs, &ldap.EntryAttribute{Name: "goauthentik.io/ldap/active", Values: []string{BoolToString(*u.IsActive)}})
|
||||
attrs = append(attrs, &ldap.EntryAttribute{Name: "goauthentik.io/ldap/superuser", Values: []string{BoolToString(u.IsSuperuser)}})
|
||||
|
||||
attrs = append(attrs, AKAttrsToLDAP(u.Attributes)...)
|
||||
|
||||
dn := pi.GetUserDN(u.Username)
|
||||
|
||||
return &ldap.Entry{DN: dn, Attributes: attrs}
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) GroupEntry(g LDAPGroup) *ldap.Entry {
|
||||
attrs := []*ldap.EntryAttribute{
|
||||
{
|
||||
Name: "cn",
|
||||
Values: []string{g.cn},
|
||||
},
|
||||
{
|
||||
Name: "uid",
|
||||
Values: []string{g.uid},
|
||||
},
|
||||
{
|
||||
Name: "gidNumber",
|
||||
Values: []string{g.gidNumber},
|
||||
},
|
||||
}
|
||||
|
||||
if g.isVirtualGroup {
|
||||
attrs = append(attrs, &ldap.EntryAttribute{
|
||||
Name: "objectClass",
|
||||
Values: []string{GroupObjectClass, "goauthentik.io/ldap/group", "goauthentik.io/ldap/virtual-group"},
|
||||
})
|
||||
} else {
|
||||
attrs = append(attrs, &ldap.EntryAttribute{
|
||||
Name: "objectClass",
|
||||
Values: []string{GroupObjectClass, "goauthentik.io/ldap/group"},
|
||||
})
|
||||
}
|
||||
|
||||
attrs = append(attrs, &ldap.EntryAttribute{Name: "member", Values: g.member})
|
||||
attrs = append(attrs, &ldap.EntryAttribute{Name: "goauthentik.io/ldap/superuser", Values: []string{BoolToString(g.isSuperuser)}})
|
||||
|
||||
if g.akAttributes != nil {
|
||||
attrs = append(attrs, AKAttrsToLDAP(g.akAttributes)...)
|
||||
}
|
||||
|
||||
return &ldap.Entry{DN: g.dn, Attributes: attrs}
|
||||
}
|
57
internal/outpost/ldap/instance_search_group.go
Normal file
57
internal/outpost/ldap/instance_search_group.go
Normal file
@ -0,0 +1,57 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
goldap "github.com/go-ldap/ldap/v3"
|
||||
ber "github.com/nmcclain/asn1-ber"
|
||||
"github.com/nmcclain/ldap"
|
||||
"goauthentik.io/api"
|
||||
)
|
||||
|
||||
func parseFilterForGroup(req api.ApiCoreGroupsListRequest, f *ber.Packet) api.ApiCoreGroupsListRequest {
|
||||
switch f.Tag {
|
||||
case ldap.FilterEqualityMatch:
|
||||
return parseFilterForGroupSingle(req, f)
|
||||
case ldap.FilterAnd:
|
||||
for _, child := range f.Children {
|
||||
req = parseFilterForGroup(req, child)
|
||||
}
|
||||
return req
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func parseFilterForGroupSingle(req api.ApiCoreGroupsListRequest, f *ber.Packet) api.ApiCoreGroupsListRequest {
|
||||
// We can only handle key = value pairs here
|
||||
if len(f.Children) < 2 {
|
||||
return req
|
||||
}
|
||||
k := f.Children[0].Value
|
||||
// Ensure key is string
|
||||
if _, ok := k.(string); !ok {
|
||||
return req
|
||||
}
|
||||
v := f.Children[1].Value
|
||||
// Null values are ignored
|
||||
if v == nil {
|
||||
return req
|
||||
}
|
||||
// Switch on type of the value, then check the key
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
switch k {
|
||||
case "cn":
|
||||
return req.Name(vv)
|
||||
case "member":
|
||||
userDN, err := goldap.ParseDN(vv)
|
||||
if err != nil {
|
||||
return req
|
||||
}
|
||||
username := userDN.RDNs[0].Attributes[0].Value
|
||||
return req.MembersByUsername([]string{username})
|
||||
}
|
||||
// TODO: Support int
|
||||
default:
|
||||
return req
|
||||
}
|
||||
return req
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user