Added wildcard for OIDC groups and users, better login flow for OIDC, updated documentation to add OIDC provider examples

This commit is contained in:
Marc Ole Bulling
2024-01-20 11:40:20 +01:00
parent 9083ac3d05
commit 15c2bd9c67
19 changed files with 614 additions and 135 deletions

View File

@@ -57,34 +57,34 @@ Available environment variables
==================================
+--------------------------+------------------------------------------------------------------------------+-------------+-----------------------------+
| Name | Action | Persistent* | Default |
+==========================+==============================================================================+=============+=============================+
| GOKAPI_CONFIG_DIR | Sets the directory for the config file | No | config |
+--------------------------+------------------------------------------------------------------------------+-------------+-----------------------------+
| GOKAPI_CONFIG_FILE | Sets the name of the config file | No | config.json |
+--------------------------+------------------------------------------------------------------------------+-------------+-----------------------------+
| GOKAPI_DATA_DIR | Sets the directory for the data | Yes | data |
+--------------------------+------------------------------------------------------------------------------+-------------+-----------------------------+
| GOKAPI_DB_NAME | Sets the name for the database file | No | gokapi.sqlite |
+--------------------------+------------------------------------------------------------------------------+-------------+-----------------------------+
| GOKAPI_LENGTH_ID | Sets the length of the download IDs. Value needs to be 5 or more | Yes | 15 |
+--------------------------+------------------------------------------------------------------------------+-------------+-----------------------------+
| GOKAPI_MAX_FILESIZE | Sets the maximum allowed file size in MB | Yes | 102400 (100GB) |
+--------------------------+------------------------------------------------------------------------------+-------------+-----------------------------+
| GOKAPI_MAX_MEMORY_UPLOAD | Sets the amount of RAM in MB that can be allocated for an upload. | Yes | 20 |
| | | | |
| | Any upload with a size greater than that will be written to a temporary file | | |
+--------------------------+------------------------------------------------------------------------------+-------------+-----------------------------+
| GOKAPI_PORT | Sets the webserver port | Yes | 53842 |
+--------------------------+------------------------------------------------------------------------------+-------------+-----------------------------+
| TMPDIR | Sets the path which contains temporary files | No | Non-Docker: Default OS path |
| | | | |
| | | | Docker: [DATA_DIR] |
+--------------------------+------------------------------------------------------------------------------+-------------+-----------------------------+
+--------------------------+------------------------------------------------------------------------------+-----------------+-----------------------------+
| Name | Action | Persistent [*]_ | Default |
+==========================+==============================================================================+=================+=============================+
| GOKAPI_CONFIG_DIR | Sets the directory for the config file | No | config |
+--------------------------+------------------------------------------------------------------------------+-----------------+-----------------------------+
| GOKAPI_CONFIG_FILE | Sets the name of the config file | No | config.json |
+--------------------------+------------------------------------------------------------------------------+-----------------+-----------------------------+
| GOKAPI_DATA_DIR | Sets the directory for the data | Yes | data |
+--------------------------+------------------------------------------------------------------------------+-----------------+-----------------------------+
| GOKAPI_DB_NAME | Sets the name for the database file | No | gokapi.sqlite |
+--------------------------+------------------------------------------------------------------------------+-----------------+-----------------------------+
| GOKAPI_LENGTH_ID | Sets the length of the download IDs. Value needs to be 5 or more | Yes | 15 |
+--------------------------+------------------------------------------------------------------------------+-----------------+-----------------------------+
| GOKAPI_MAX_FILESIZE | Sets the maximum allowed file size in MB | Yes | 102400 (100GB) |
+--------------------------+------------------------------------------------------------------------------+-----------------+-----------------------------+
| GOKAPI_MAX_MEMORY_UPLOAD | Sets the amount of RAM in MB that can be allocated for an upload. | Yes | 20 |
| | | | |
| | Any upload with a size greater than that will be written to a temporary file | | |
+--------------------------+------------------------------------------------------------------------------+-----------------+-----------------------------+
| GOKAPI_PORT | Sets the webserver port | Yes | 53842 |
+--------------------------+------------------------------------------------------------------------------+-----------------+-----------------------------+
| TMPDIR | Sets the path which contains temporary files | No | Non-Docker: Default OS path |
| | | | |
| | | | Docker: [DATA_DIR] |
+--------------------------+------------------------------------------------------------------------------+-----------------+-----------------------------+
\* Variables that are persistent must be submitted during the first start when Gokapi creates a new config file. They can be omitted afterwards. Non-persistent variables need to be set on every start.
.. [*] Variables that are persistent must be submitted during the first start when Gokapi creates a new config file. They can be omitted afterwards. Non-persistent variables need to be set on every start.

View File

@@ -57,3 +57,7 @@ html_static_path = ['']
master_doc = 'index'
#autosectionlabel_prefix_document = True
html_theme_options = {
'navigation_depth': 5,
}

View File

@@ -1,6 +1,7 @@
.. _contributions:
=============
Contributions
=============

260
docs/examples.rst Normal file
View File

@@ -0,0 +1,260 @@
.. _examples:
===========================
Examples
===========================
*********************************
OpenID Connect Configuration
*********************************
.. _oidcconfig_authelia:
Authelia
^^^^^^^^^^^^
Server Configuration
""""""""""""""""""""""
.. note::
This guide has been written for version 4.37.5
See the `Authelia documentation <https://www.authelia.com/configuration/identity-providers/open-id-connect/>`_ on how to setup an OIDC server. An example file would be as followed:
.. code-block:: YAML
identity_providers:
oidc:
hmac_secret: noz1Aow6Soo9lieyus2E_EXAMPLE_KEY
issuer_private_key: |
-----BEGIN PRIVATE KEY-----
ohf2shae1bahph7ahSh1
EXAMPLE_KEY
EP3EihoPhei9iingai0v==
-----END PRIVATE KEY-----
access_token_lifespan: 1h
authorize_code_lifespan: 1m
id_token_lifespan: 1h
refresh_token_lifespan: 90m
enable_client_debug_messages: false
enforce_pkce: public_clients_only
cors:
endpoints:
- authorization
- token
- revocation
- introspection
allowed_origins:
- "https://*.your.domain"
allowed_origins_from_client_redirect_uris: false
clients:
- id: gokapi-dev
description: Gokapi Example
secret: 'AhXeV7_EXAMPLE_KEY'
sector_identifier: ''
public: false
authorization_policy: one_factor
consent_mode: pre-configured
pre_configured_consent_duration: 1w
audience: []
scopes:
- openid
- email
- profile
- groups
redirect_uris:
- https://gokapi.website.com/oauth-callback
userinfo_signing_algorithm: none
* Set ``authorization_policy`` to ``two_factor`` to use OTP or a hardware key.
* If ``consent_mode`` is ``pre-configured``, the user has the option to remember consent. That way you can use a lower ``Recheck identity`` interval in Gokapi. Logout through the Gokapi interface will not be possible anymore, unless the user logs out their Authelia account. If the option is set to ``explicit``, the user always has to grant the permission aftter the ``Recheck identity`` interval has passed
* ``scopes`` may exclude ``email`` and ``groups`` if these are not required for authentication, e.g. if all users registered with Authelia may access Gokapi.
* Make sure ``redirect_uris`` is set to the correct value
Gokapi Configuration
""""""""""""""""""""""
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
| Gokapi Configuration | Input | Example |
+==========================+===========================================================+=========================================+
| Provider URL | URL to Authelia Server | \https://auth.autheliaserver.com |
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
| Client ID | Client ID provided in config | gokapi-dev |
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
| Client Secret | Client secret provided in config | AhXeV7_EXAMPLE_KEY |
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
| Recheck identity | If mode is ``pre-configured``, use a low interval. | 12 hours |
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
| Restrict to user | Check this if only certain users shall be allowed to | checked |
| | | |
| | access Gokapi admin menu | |
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
| Scope identifier (user) | Use a scope that is unique to the user, e.g. the username | email |
| | | |
| | or the email | |
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
| Authorised users | Enter all users, separated by semicolon | \*\@company.com;admin\@othercompany.com |
| | | |
| | ``*`` can be used as a wildcard | |
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
| Restrict to group | Check this if only users from certain groups shall be | checked |
| | | |
| | allowed to access Gokapi admin menu | |
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
| Scope identifier (group) | Use a scope that lists the user's groups | groups |
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
| Authorised groups | Enter all groups, separated by semicolon | dev;admins;gokapi-* |
| | | |
| | ``*`` can be used as a wildcard | |
+--------------------------+-----------------------------------------------------------+-----------------------------------------+
.. _oidcconfig_keycloak:
Keycloak
^^^^^^^^^^^^
.. note::
This guide has been written for version 23.0.4
Server Configuration
""""""""""""""""""""""
Creating the client
**********************
#. In your realm (default: master) click on ``[Manage] Clients`` and then ``Create Client``
* Client Type: OpenID Connect
* Client ID: a unique ID, ``gokapi-dev`` is used in this example
#. Click ``Next``
* Set ``Client authentication`` to on
* Only select ``Standard flow`` in ``Authentication flow``
#. Click ``Next``
* Add your redirect URL, e.g. ``https://gokapi.website.com/oauth-callback``
* Click ``Save``
#. Click ``Credentials``
* Note the ``Client Secret``
Addding a scope for exposing groups (optional)
*****************************************************
#. In the realm click on ``[Manage] Client Scopes`` and then ``Create Scope``
* Name: groups
* Type: Optional
* Protocol: OpenID Connect
* Click ``Save``
#. Click ``Mappers``
* Click ``Add predefined mapper``
* Search for ``groups`` and tick
* Click ``Add``
#. In the realm click on ``[Manage] Clients`` and then ``gokapi-dev``
* Click ``Client Scopes``
* Click ``Add Client Scope``
* Select ``groups`` and click ``Add / Optional``
Gokapi Configuration
""""""""""""""""""""""
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
| Gokapi Configuration | Input | Example |
+==========================+===========================================================+============================================+
| Provider URL | URL to Keycloak realm | \http://keycloak.server.com/realms/master/ |
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
| Client ID | Client ID provided | gokapi-dev |
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
| Client Secret | Client secret provided | AhXeV7_EXAMPLE_KEY |
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
| Recheck identity | If mode is ``pre-configured``, use a low interval. | 12 hours |
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
| Restrict to user | Check this if only certain users shall be allowed to | checked |
| | | |
| | access Gokapi admin menu | |
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
| Scope identifier (user) | Use a scope that is unique to the user, e.g. the username | email |
| | | |
| | or the email | |
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
| Authorised users | Enter all users, separated by semicolon | \*\@company.com;admin\@othercompany.com |
| | | |
| | ``*`` can be used as a wildcard | |
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
| Restrict to group | Check this if only users from certain groups shall be | checked |
| | | |
| | allowed to access Gokapi admin menu | |
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
| Scope identifier (group) | Use a scope that lists the user's groups | groups |
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
| Authorised groups | Enter all groups, separated by semicolon | dev;admins;gokapi-* |
| | | |
| | ``*`` can be used as a wildcard | |
+--------------------------+-----------------------------------------------------------+--------------------------------------------+
.. note::
Logout through the Gokapi interface will not be possible anymore, unless the user logs out their Keycload account.
.. _oidcconfig_google:
Google
^^^^^^^^^^^^
Server Configuration
""""""""""""""""""""""
.. note::
This guide has been last updated in January 2024 and is based on `this documentation <https://support.google.com/cloud/answer/6158849>`_
#. Go to the `Google Cloud Platform Console <https://console.cloud.google.com/>`_.
#. From the projects list, select a project or create a new one.
#. If the APIs & services page isn't already open, open the console left side menu and select APIs & services.
#. On the left, click Credentials.
#. Click New Credentials, then select OAuth client ID.
#. Select Application Type ``Webapplication``
#. Add the correct Gokapi redirect URL and click Create
Gokapi Configuration
""""""""""""""""""""""
+-------------------------+--------------------------------------------------+----------------------------------+
| Gokapi Configuration | Input | Example |
+=========================+==================================================+==================================+
| Provider URL | \https://accounts.google.com | \https://accounts.google.com |
+-------------------------+--------------------------------------------------+----------------------------------+
| Client ID | Client ID provided | XXX.apps.googleusercontent.com |
+-------------------------+--------------------------------------------------+----------------------------------+
| Client Secret | Client secret provided | AhXeV7_EXAMPLE_KEY |
+-------------------------+--------------------------------------------------+----------------------------------+
| Recheck identity | Use a low interval. | 12 hours |
+-------------------------+--------------------------------------------------+----------------------------------+
| Restrict to user | Check this, otherwise any Google user can access | checked |
| | | |
| | | |
| | your Gokapi admin menu | |
+-------------------------+--------------------------------------------------+----------------------------------+
| Scope identifier (user) | email | email |
+-------------------------+--------------------------------------------------+----------------------------------+
| Authorised users | Enter all users, separated by semicolon | user\@gmail.com;admin\@gmail.com |
+-------------------------+--------------------------------------------------+----------------------------------+
| Restrict to group | Unsupported | unchecked |
+-------------------------+--------------------------------------------------+----------------------------------+

View File

@@ -1,5 +1,6 @@
.. _index:
===========================
Gokapi
===========================
@@ -14,11 +15,12 @@ Contents
========
.. toctree::
:maxdepth: 2
:maxdepth: 2
setup
usage
update
advanced
contributions
changelog
setup
usage
update
advanced
examples
contributions
changelog

View File

@@ -110,30 +110,78 @@ The default authentication method. A single admin user will be generated that au
OAuth2 OpenID Connect
************************
Use this to authenticate with an OIDC server, eg. Google, Github or an internal server. *Note:* If a user is revoked on the OIDC server, it might take several days to affect the Gokapi session.
Setup interface
========================
+---------------+---------------------------------------------------------------------------------+---------------------------------------------+
| Option | Expected Entry | Example |
+===============+=================================================================================+=============================================+
| Provider URL | The URL to connect to the OIDC server | https://accounts.google.com |
+---------------+---------------------------------------------------------------------------------+---------------------------------------------+
| Client ID | Client ID provided by the OIDC server | [random String] |
+---------------+---------------------------------------------------------------------------------+---------------------------------------------+
| Client Secret | Client secret provided by the OIDC server | [random String] |
+---------------+---------------------------------------------------------------------------------+---------------------------------------------+
| Allowed users | List of users that is allowed to log in as an admin. | gokapiuser@gmail.com;companyadmin@gmail.com |
| | Separate users with a semicolon or leave blank to allow any authenticated user | |
+---------------+---------------------------------------------------------------------------------+---------------------------------------------+
Use this to authenticate with an OIDC server, e.g. Google or an internal server like Authelia or Keycloak.
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
| Option | Expected Entry | Example |
+====================+===================================================================================================+=========================================+
| Provider URL | The URL to connect to the OIDC server | https://accounts.google.com |
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
| Client ID | Client ID provided by the OIDC server | [random String] |
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
| Client Secret | Client secret provided by the OIDC server | [random String] |
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
| Recheck identity | How often to recheck identity. | 12 hours |
| | | |
| | If the OIDC server is configured to remember the consent, the user should not receive any further | |
| | | |
| | login prompts and it can be ensured, that the user still exist on the server. | |
| | | |
| | Otherwise the user has actively grant access every time the identity is rechecked. In that case | |
| | | |
| | a higher interval would make sense. | |
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
| Restrict to users | Only allow authorised users to access Gokapi that are listed below | true |
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
| Scope for users | The OIDC scope that contains the user info | email |
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
| Authorised users | List of users that are authorised to log in as an admin, separated by semicolon. | \*\@company.com;admin\@othercompany.com |
| | | |
| | ``*`` can be used as a wildcard | |
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
| Restrict to groups | Only allow users that are part of authorised groups to access Gokapi | true |
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
| Scope for groups | The OIDC scope that contains the group info | groups |
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
| Authorised groups | List of groups that are authorised to log their users in as an admin, separated by semicolon. | admin;dev;gokapi-\* |
| | | |
| | ``*`` can be used as a wildcard | |
+--------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------+
.. note::
If login is restricted to users and groups, both need to be present for a user to access. That means if a user has only one of the two factors, access to the admin menu will be denied.
.. note::
A user will be authenticated until the time specified in ``Recheck identity`` has passed. To log out all users immediately, re-run the setup with `--reconfigure`` and complete it. Thereafter all active session will be deleted.
.. note::
If the OIDC provider is set up to remember consent, it might not be possible to log out through the Gokapi interface
OIDC client/server configuration
=======================================
When creating an OIDC client on the server, you will need to provide a **redirection URL**. Enter ``http[s]://[gokapi URL]/oauth-callback``
Tutorial for configuring OIDC servers and the correct client settings for Gokapi can be found in the :ref:`examples` page for the following servers:
* :ref:`oidcconfig_authelia`
* :ref:`oidcconfig_keycloak`
* :ref:`oidcconfig_google`
You can find a guide on how to create an OIDC client with Github at `Setting up GitHub OAuth 2.0 <https://docs.readme.com/docs/setting-up-github-oauth>`_ and a guide for Google at `Setting up OAuth 2.0 <https://support.google.com/cloud/answer/6158849>`_.
Header Authentication
************************
Only use this if you are running Gokapi behind a reverse proxy that is capable of authenticating users, e.g. by using Authelia or Authentik.
Only use this if you are running Gokapi behind a reverse proxy that is capable of authenticating users, e.g. by using Authelia or Authentik. Keycloak does apparently not support this feauture.
Enter the key of the header that returns the username. For Authelia this would be ``Remote-User`` and for Authentik ``X-authentik-username``.
Separate users with a semicolon or leave blank to allow any authenticated user, e.g. ``gokapiuser@gmail.com;companyadmin@gmail.com``
@@ -158,7 +206,8 @@ This option disables Gokapis internal authentication completely, except for API
- ``/uploadComplete``
- ``/uploadStatus``
**Warning:** This option has potential to be *very* dangerous, only proceed if you know what you are doing!
.. warning::
This option has potential to be *very* dangerous, only proceed if you know what you are doing!
@@ -178,7 +227,10 @@ Stores files locally in the subdirectory ``data`` by default.
Cloudstorage
*********************
Stores files remotely on an S3 compatible server, e.g. Amazon AWS S3 or Backblaze B2. Please note that files will be stored in plain-text, if no encryption is selected later on.
.. note::
Files will be stored in plain-text, if no encryption is selected later on in the setup
Stores files remotely on an S3 compatible server, e.g. Amazon AWS S3 or Backblaze B2.
It is highly recommended to create a new bucket for Gokapi and set it to "private", so that no file can be downloaded externally. For each download request Gokapi will create a public URL that is only valid for a couple of seconds, so that the file can be downloaded from the external server directly instead of routing it through the local server.
@@ -205,7 +257,8 @@ The following data needs to be provided:
Encryption
""""""""""""""
*Warning: Encryption has not been audited.*
.. warning::
Encryption has not been audited.
There are three different encryption levels, level 1 encrypts only local files and level 2 encrypts local and files stored on cloud storage (e.g. AWS S3). Decryption of files on remote storage is done client-side, for which a 2MB library needs to be downloaded on first visit. End-to-End encryption (level 3) encrypts the files client-side, therefore even if the Gokapi server has been compromised, no data should leak to the attacker. If the decryption is done client-side, the dowload on mobile devices may be significantly slower.
@@ -228,7 +281,8 @@ You can choose to store the key in the configuration file, which is preferred if
If you are concerned that the configuration file can be read, you can also choose to enter a master password on startup. This needs to be entered in the command line however and Gokapi will not be able to start without it.
Please note: If you re-run the setup and enable encryption, unencrypted files will stay unencrypted. If you change any configuration related to encryption, all already encrypted files will be deleted.
.. note::
If you re-run the setup and enable encryption, unencrypted files will stay unencrypted. If you change any configuration related to encryption, all already encrypted files will be deleted.
************************
Changing Configuration
@@ -239,6 +293,9 @@ To change any settings set in the initial setup (e.g. your password or storage l
If you are using Docker, shut down the running instance and create a new temporary container with the follwing command: ::
docker run --rm -p 127.0.0.1:53842:53842 -v gokapi-data:/app/data -v gokapi-config:/app/config f0rc3/gokapi:latest /app/gokapi --reconfigure
.. note::
After completing the setup, all users will be logged out

View File

@@ -11,7 +11,7 @@ import (
)
// CurrentConfigVersion is the version of the configuration structure. Used for upgrading
const CurrentConfigVersion = 17
const CurrentConfigVersion = 18
// DoUpgrade checks if an old version is present and updates it to the current version if required
func DoUpgrade(settings *models.Configuration, env *environment.Environment) bool {
@@ -59,10 +59,11 @@ func updateConfig(settings *models.Configuration, env *environment.Environment)
}
}
// < v1.8.2
if settings.ConfigVersion < 17 {
if settings.ConfigVersion < 18 {
if len(settings.Authentication.OAuthUsers) > 0 {
settings.Authentication.OAuthUserScope = "email"
}
settings.Authentication.OAuthRecheckInterval = 168
}
}

View File

@@ -476,6 +476,7 @@ type setupValues struct {
OAuthScopeGroup setupEntry `form:"oauth_scope_groups"`
OAuthRestrictUser setupEntry `form:"oauth_restrict_users" isBool:"true"`
OAuthRestrictGroups setupEntry `form:"oauth_restrict_groups" isBool:"true"`
OAuthRecheckInterval setupEntry `form:"oauth_recheck_interval" isInt:"true"`
AuthHeaderKey setupEntry `form:"auth_headerkey"`
AuthHeaderUsers setupEntry `form:"auth_header_users"`
StorageSelection setupEntry `form:"storage_sel"`

View File

@@ -86,7 +86,7 @@
</p>
{{ else }}
<p>
You can now change the Gokapi configuration. For further information please refer to the <a href="https://gokapi.readthedocs.io/en/stable/" title="Gokapi Documentation" target="_blank">documentation</a>.
You can now change the Gokapi configuration. For further information please refer to the <a href="https://gokapi.readthedocs.io/en/stable/" title="Gokapi Documentation" target="_blank" rel="noopener noreferrer">documentation</a>.
</p>
{{ end }}
@@ -229,7 +229,7 @@
<div class="wizard-input-section">
<div class="form-group">
<p>
Please enter the OIDC client configuration. Multiple users and groups can be separated with a semicolon. The documentationTODO has examples on how to configure for different providers.<br>
Please enter the OIDC client configuration. Multiple users and groups can be separated with a semicolon. The <a href="https://gokapi.readthedocs.io/en/stable/setup.html#oauth2-openid-connect" target="_blank" rel="noopener noreferrer">documentation</a> has examples on how to configure for different providers.<br>
</p>
<div class="col-sm-8" style="width:90%">
@@ -246,7 +246,18 @@
<label for="oauth_secret">Client Secret:</label>
<input type="text" class="form-control" id="oauth_secret" name="oauth_secret" placeholder="Client Secret" data-min="1" required data-validate="validateMinLength">
</div>
<div class="col-sm-8" style="width:90%">
Recheck identity every
<select name="oauth_recheck_interval" id="oauth_recheck_interval" style="width:350px;" class="select form-control">
<option value="6"> 6 hours</option>
<option value="12" selected>12 hours</option>
<option value="24">24 hours</option>
<option value="72"> 3 days</option>
<option value="168">7 days</option>
<option value="336">14 days</option>
<option value="720">30 days</option>
</select>
</div>
<div class="col-sm-8" style="width:90%">
<br>Restrict to:
<div class="oauthscopecontainer">
@@ -266,16 +277,15 @@
<input type="text" id="oauth_allowed_groups" name="oauth_allowed_groups" class="input-field" placeholder="Authorised groups" data-min="1" data-validate="validateMinLength" disabled>
</div>
</div>
</div>
</div>
<div class="col-sm-8" style="width:90%">
<label for="oauth_redir"><br>Redirection URL:</label>
<label for="oauth_redir"><br>Redirection URL:</label>
{{ if .IsInitialSetup }}
<input type="text" class="form-control" id="oauth_redir" name="oauth_redir" disabled value="http://127.0.0.1:53842/oauth-callback">
{{ else }}
<input type="text" class="form-control" id="oauth_redir" name="oauth_redir" disabled value="{{ .Settings.ServerUrl }}oauth-callback">
{{ end }}
</div>
</div><br><br>
</div>
@@ -698,6 +708,7 @@ function TestAWS(button) {
document.getElementById("oauth_scope_groups").value = "{{ .Auth.OAuthGroupScope }}";
document.getElementById("oauth_allowed_groups").disabled = false;
document.getElementById("oauth_allowed_groups").value = "{{ .OAuthGroups }}";
document.getElementById("oauth_recheck_interval").value = "{{ .Auth.OAuthRecheckInterval }}";
{{ end }}
break;
case 2:
@@ -721,6 +732,12 @@ function TestAWS(button) {
wizard.on("submit", function(wizard) {
/* enable inputs again, otherwise they will not be submitted */
document.getElementById("oauth_scope_groups").disabled = false;
document.getElementById("oauth_scope_users").disabled = false;
document.getElementById("oauth_allowed_users").disabled = false;
document.getElementById("oauth_allowed_groups").disabled = false;
$.ajax({
type: "POST",

View File

@@ -2,18 +2,19 @@ package models
// AuthenticationConfig holds configuration on how to authenticate to Gokapi admin menu
type AuthenticationConfig struct {
Method int `json:"Method"`
SaltAdmin string `json:"SaltAdmin"`
SaltFiles string `json:"SaltFiles"`
Username string `json:"Username"`
Password string `json:"Password"`
HeaderKey string `json:"HeaderKey"`
OAuthProvider string `json:"OauthProvider"`
OAuthClientId string `json:"OAuthClientId"`
OAuthClientSecret string `json:"OAuthClientSecret"`
OAuthUserScope string `json:"OauthUserScope"`
OAuthGroupScope string `json:"OauthGroupScope"`
HeaderUsers []string `json:"HeaderUsers"`
OAuthGroups []string `json:"OAuthGroups"`
OAuthUsers []string `json:"OauthUsers"`
Method int `json:"Method"`
SaltAdmin string `json:"SaltAdmin"`
SaltFiles string `json:"SaltFiles"`
Username string `json:"Username"`
Password string `json:"Password"`
HeaderKey string `json:"HeaderKey"`
OAuthProvider string `json:"OauthProvider"`
OAuthClientId string `json:"OAuthClientId"`
OAuthClientSecret string `json:"OAuthClientSecret"`
OAuthUserScope string `json:"OauthUserScope"`
OAuthGroupScope string `json:"OauthGroupScope"`
OAuthRecheckInterval int `json:"OAuthRecheckInterval"`
HeaderUsers []string `json:"HeaderUsers"`
OAuthGroups []string `json:"OAuthGroups"`
OAuthUsers []string `json:"OauthUsers"`
}

View File

@@ -301,7 +301,12 @@ func showLogin(w http.ResponseWriter, r *http.Request) {
return
}
if configuration.Get().Authentication.Method == authentication.OAuth2 {
redirect(w, "oauth-login")
// If user clicked logout, force consent
if r.URL.Query().Has("consent") {
redirect(w, "oauth-login?consent=true")
} else {
redirect(w, "oauth-login")
}
return
}
err := r.ParseForm()
@@ -311,7 +316,9 @@ func showLogin(w http.ResponseWriter, r *http.Request) {
failedLogin := false
if pw != "" && user != "" {
if authentication.IsCorrectUsernameAndPassword(user, pw) {
sessionmanager.CreateSession(w)
isOauth := configuration.Get().Authentication.Method == authentication.OAuth2
interval := configuration.Get().Authentication.OAuthRecheckInterval
sessionmanager.CreateSession(w, isOauth, interval)
redirect(w, "admin")
return
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/forceu/gokapi/internal/helper"
"github.com/forceu/gokapi/internal/models"
"github.com/forceu/gokapi/internal/storage"
"github.com/forceu/gokapi/internal/webserver/authentication"
"github.com/forceu/gokapi/internal/webserver/authentication/sessionmanager"
"github.com/forceu/gokapi/internal/webserver/fileupload"
"net/http"
@@ -285,7 +286,9 @@ func isAuthorisedForApi(w http.ResponseWriter, request apiRequest) bool {
sendError(w, http.StatusBadRequest, "Invalid request")
return false
}
if IsValidApiKey(request.apiKey, true, perm) || sessionmanager.IsValidSession(w, request.request) {
isOauth := configuration.Get().Authentication.Method == authentication.OAuth2
interval := configuration.Get().Authentication.OAuthRecheckInterval
if IsValidApiKey(request.apiKey, true, perm) || sessionmanager.IsValidSession(w, request.request, isOauth, interval) {
return true
}
sendError(w, http.StatusUnauthorized, "Unauthorized")

View File

@@ -5,10 +5,12 @@ import (
"encoding/json"
"fmt"
"github.com/forceu/gokapi/internal/configuration"
"github.com/forceu/gokapi/internal/helper"
"github.com/forceu/gokapi/internal/models"
"github.com/forceu/gokapi/internal/webserver/authentication/sessionmanager"
"io"
"net/http"
"regexp"
"strings"
)
@@ -21,7 +23,7 @@ const Internal = 0
// OAuth2 authentication retrieves the users email with Open Connect ID
const OAuth2 = 1
// Header authentication relies on a header from a reverse proxy to parse the user name
// Header authentication relies on a header from a reverse proxy to parse the username
const Header = 2
// Disabled authentication ignores all internal authentication procedures. A reverse proxy needs to restrict access
@@ -67,17 +69,40 @@ func isGrantedHeader(r *http.Request) bool {
func isUserInArray(userEntered string, allowedUsers []string) bool {
for _, allowedUser := range allowedUsers {
if strings.ToLower(allowedUser) == strings.ToLower(userEntered) {
matches, err := matchesWithWildcard(strings.ToLower(allowedUser), strings.ToLower(userEntered))
helper.Check(err)
if matches {
return true
}
}
return false
}
func matchesWithWildcard(pattern, input string) (bool, error) {
components := strings.Split(pattern, "*")
if len(components) == 1 {
// if len is 1, there are no *'s, return exact match pattern
return regexp.MatchString("^"+pattern+"$", input)
}
var result strings.Builder
for i, literal := range components {
// Replace * with .*
if i > 0 {
result.WriteString(".*")
}
// Quote any regular expression meta characters in the
// literal text.
result.WriteString(regexp.QuoteMeta(literal))
}
return regexp.MatchString("^"+result.String()+"$", input)
}
func isGroupInArray(userGroups []string, allowedGroups []string) bool {
for _, group := range userGroups {
for _, allowedGroup := range allowedGroups {
if strings.ToLower(allowedGroup) == strings.ToLower(group) {
matches, err := matchesWithWildcard(strings.ToLower(allowedGroup), strings.ToLower(group))
helper.Check(err)
if matches {
return true
}
}
@@ -85,7 +110,7 @@ func isGroupInArray(userGroups []string, allowedGroups []string) bool {
return false
}
func extractOauthGroups(userInfo OAuthUserInfo, groupScope string) ([]string, error) {
func extractOauthGroups(userInfo OAuthUserClaims, groupScope string) ([]string, error) {
var claims json.RawMessage
var data map[string]interface{}
@@ -113,7 +138,7 @@ func extractOauthGroups(userInfo OAuthUserInfo, groupScope string) ([]string, er
return groups, nil
}
func extractFieldValue(userInfo OAuthUserInfo, fieldName string) (string, error) {
func extractFieldValue(userInfo OAuthUserClaims, fieldName string) (string, error) {
var claims json.RawMessage
err := userInfo.Claims(&claims)
@@ -141,31 +166,41 @@ func extractFieldValue(userInfo OAuthUserInfo, fieldName string) (string, error)
}
// OAuthUserInfo is used to make testing easier. This results in an additional parameter for the subject unfortunately
type OAuthUserInfo interface {
type OAuthUserInfo struct {
Subject string
Email string
ClaimsSent OAuthUserClaims
}
// OAuthUserClaims contains the claims
type OAuthUserClaims interface {
Claims(v interface{}) error
}
// CheckOauthUserAndRedirect checks if the user is allowed to use the Gokapi instance
func CheckOauthUserAndRedirect(userInfo OAuthUserInfo, userInfoSubject string, w http.ResponseWriter) error {
func CheckOauthUserAndRedirect(userInfo OAuthUserInfo, w http.ResponseWriter) error {
var username string
var groups []string
var err error
if authSettings.OAuthUserScope != "" {
username, err = extractFieldValue(userInfo, authSettings.OAuthUserScope)
if err != nil {
return err
if authSettings.OAuthUserScope == "email" {
username = userInfo.Email
} else {
username, err = extractFieldValue(userInfo.ClaimsSent, authSettings.OAuthUserScope)
if err != nil {
return err
}
}
}
if authSettings.OAuthGroupScope != "" {
groups, err = extractOauthGroups(userInfo, authSettings.OAuthGroupScope)
groups, err = extractOauthGroups(userInfo.ClaimsSent, authSettings.OAuthGroupScope)
if err != nil {
return err
}
}
if isValidOauthUser(userInfoSubject, username, groups) {
// TODO revoke session if oauth is not valid any more
sessionmanager.CreateSession(w)
if isValidOauthUser(userInfo, username, groups) {
sessionmanager.CreateSession(w, authSettings.Method == OAuth2, authSettings.OAuthRecheckInterval)
redirect(w, "admin")
return nil
}
@@ -173,8 +208,8 @@ func CheckOauthUserAndRedirect(userInfo OAuthUserInfo, userInfoSubject string, w
return nil
}
func isValidOauthUser(userInfoSubject string, username string, groups []string) bool {
if userInfoSubject == "" {
func isValidOauthUser(userInfo OAuthUserInfo, username string, groups []string) bool {
if userInfo.Subject == "" {
return false
}
isValidUser := true
@@ -190,7 +225,7 @@ func isValidOauthUser(userInfoSubject string, username string, groups []string)
// isGrantedSession returns true if the user holds a valid internal session cookie
func isGrantedSession(w http.ResponseWriter, r *http.Request) bool {
return sessionmanager.IsValidSession(w, r)
return sessionmanager.IsValidSession(w, r, authSettings.Method == OAuth2, authSettings.OAuthRecheckInterval)
}
// IsCorrectUsernameAndPassword checks if a provided username and password is correct
@@ -216,7 +251,11 @@ func Logout(w http.ResponseWriter, r *http.Request) {
if authSettings.Method == Internal || authSettings.Method == OAuth2 {
sessionmanager.LogoutSession(w, r)
}
redirect(w, "login")
if authSettings.Method == OAuth2 {
redirect(w, "login?consent=true")
} else {
redirect(w, "login")
}
}
// IsLogoutAvailable returns true if a logout button should be shown with the current form of authentication

View File

@@ -3,7 +3,7 @@ package authentication
import (
"encoding/json"
"errors"
"github.com/coreos/go-oidc/v3/oidc"
"fmt"
"github.com/forceu/gokapi/internal/configuration"
"github.com/forceu/gokapi/internal/models"
"github.com/forceu/gokapi/internal/test"
@@ -107,23 +107,90 @@ func TestRedirect(t *testing.T) {
func TestIsValidOauthUser(t *testing.T) {
Init(modelOauth)
info := oidc.UserInfo{Subject: "randomid"}
test.IsEqualBool(t, isValidOauthUser(info.Subject, "", []string{}), true)
test.IsEqualBool(t, isValidOauthUser(info.Subject, "test1", []string{"test2"}), true)
info := OAuthUserInfo{Subject: "randomid"}
test.IsEqualBool(t, isValidOauthUser(info, "", []string{}), true)
test.IsEqualBool(t, isValidOauthUser(info, "test1", []string{"test2"}), true)
authSettings.OAuthUserScope = "user"
authSettings.OAuthUsers = []string{"otheruser"}
test.IsEqualBool(t, isValidOauthUser(info.Subject, "test1", []string{}), false)
test.IsEqualBool(t, isValidOauthUser(info.Subject, "otheruser", []string{}), true)
test.IsEqualBool(t, isValidOauthUser(info, "test1", []string{}), false)
test.IsEqualBool(t, isValidOauthUser(info, "otheruser", []string{}), true)
authSettings.OAuthGroupScope = "group"
authSettings.OAuthGroups = []string{"othergroup"}
test.IsEqualBool(t, isValidOauthUser(info.Subject, "test1", []string{}), false)
test.IsEqualBool(t, isValidOauthUser(info.Subject, "otheruser", []string{}), false)
test.IsEqualBool(t, isValidOauthUser(info.Subject, "test1", []string{"testgroup"}), false)
test.IsEqualBool(t, isValidOauthUser(info.Subject, "test1", []string{"testgroup", "othergroup"}), false)
test.IsEqualBool(t, isValidOauthUser(info.Subject, "otheruser", []string{"othergroup"}), true)
test.IsEqualBool(t, isValidOauthUser(info.Subject, "otheruser", []string{"testgroup", "othergroup"}), true)
test.IsEqualBool(t, isValidOauthUser(info, "test1", []string{}), false)
test.IsEqualBool(t, isValidOauthUser(info, "otheruser", []string{}), false)
test.IsEqualBool(t, isValidOauthUser(info, "test1", []string{"testgroup"}), false)
test.IsEqualBool(t, isValidOauthUser(info, "test1", []string{"testgroup", "othergroup"}), false)
test.IsEqualBool(t, isValidOauthUser(info, "otheruser", []string{"othergroup"}), true)
test.IsEqualBool(t, isValidOauthUser(info, "otheruser", []string{"testgroup", "othergroup"}), true)
info.Subject = ""
test.IsEqualBool(t, isValidOauthUser(info.Subject, "otheruser", []string{"testgroup", "othergroup"}), false)
test.IsEqualBool(t, isValidOauthUser(info, "otheruser", []string{"testgroup", "othergroup"}), false)
}
func TestWildcardMatch(t *testing.T) {
type testPattern struct {
Pattern string
Input string
Result bool
}
tests := []testPattern{{
Pattern: "test",
Input: "test",
Result: true,
}, {
Pattern: "test*",
Input: "test",
Result: true,
}, {
Pattern: "*test",
Input: "test",
Result: true,
}, {
Pattern: "te*st",
Input: "test",
Result: true,
}, {
Pattern: "test*",
Input: "1test",
Result: false,
}, {
Pattern: "*test",
Input: "test1",
Result: false,
}, {
Pattern: "te*st",
Input: "teeeeeeeest",
Result: true,
}, {
Pattern: "te*st",
Input: "teast",
Result: true,
}, {
Pattern: "te*st",
Input: "te@st",
Result: true,
}, {
Pattern: "*@github.com",
Input: "email@github.com",
Result: true,
}, {
Pattern: "@github.com",
Input: "email@github.com",
Result: false,
}, {
Pattern: "@github.com",
Input: "email@gokapi.com",
Result: false,
}, {
Pattern: "*@github.com",
Input: "email@gokapi.com",
Result: false,
}}
for _, patternTest := range tests {
fmt.Printf("Testing: %s == %s, expecting %v\n", patternTest.Pattern, patternTest.Input, patternTest.Result)
result, err := matchesWithWildcard(patternTest.Pattern, patternTest.Input)
test.IsNil(t, err)
test.IsEqualBool(t, result, patternTest.Result)
}
}
func TestLogout(t *testing.T) {
@@ -133,11 +200,10 @@ func TestLogout(t *testing.T) {
}
type testInfo struct {
Output []byte
Subject string
Output []byte
}
func (t *testInfo) Claims(v interface{}) error {
func (t testInfo) Claims(v interface{}) error {
if t.Output == nil {
return errors.New("oidc: claims not set")
}
@@ -146,32 +212,42 @@ func (t *testInfo) Claims(v interface{}) error {
func TestCheckOauthUser(t *testing.T) {
Init(modelOauth)
info := testInfo{Output: []byte(`{"amr":["pwd","hwk","user","pin","mfa"],"aud":["gokapi-dev"],"auth_time":1705573822,"azp":"gokapi-dev","client_id":"gokapi-dev","email":"test@test.com","email_verified":true,"groups":["admins","dev"],"iat":1705577400,"iss":"https://auth.test.com","name":"gokapi","preferred_username":"gokapi","rat":1705577400,"sub":"944444cf3e-0546-44f2-acfa-a94444444360"}`)}
output, err := getOuthUserOutput(t, &info, info.Subject)
info := OAuthUserInfo{
ClaimsSent: testInfo{Output: []byte(`{"amr":["pwd","hwk","user","pin","mfa"],"aud":["gokapi-dev"],"auth_time":1705573822,"azp":"gokapi-dev","client_id":"gokapi-dev","email":"test@test.com","email_verified":true,"groups":["admins","dev"],"iat":1705577400,"iss":"https://auth.test.com","name":"gokapi","preferred_username":"gokapi","rat":1705577400,"sub":"944444cf3e-0546-44f2-acfa-a94444444360"}`)},
}
output, err := getOuthUserOutput(t, info)
test.IsNil(t, err)
test.IsEqualString(t, redirectsToSite(output), "error-auth")
info.Subject = "random"
output, err = getOuthUserOutput(t, &info, info.Subject)
output, err = getOuthUserOutput(t, info)
test.IsNil(t, err)
test.IsEqualString(t, redirectsToSite(output), "admin")
info.Email = "test@test.com"
authSettings.OAuthUserScope = "email"
authSettings.OAuthUsers = []string{"otheruser"}
output, err = getOuthUserOutput(t, &info, info.Subject)
output, err = getOuthUserOutput(t, info)
test.IsNil(t, err)
test.IsEqualString(t, redirectsToSite(output), "error-auth")
authSettings.OAuthUsers = []string{"test@test.com"}
output, err = getOuthUserOutput(t, &info, info.Subject)
output, err = getOuthUserOutput(t, info)
test.IsNil(t, err)
test.IsEqualString(t, redirectsToSite(output), "admin")
authSettings.OAuthUsers = []string{"otheruser@test"}
output, err = getOuthUserOutput(t, &info, info.Subject)
output, err = getOuthUserOutput(t, info)
test.IsNil(t, err)
test.IsEqualString(t, redirectsToSite(output), "error-auth")
authSettings.OAuthUserScope = "invalidScope"
output, err = getOuthUserOutput(t, &info, info.Subject)
output, err = getOuthUserOutput(t, info)
test.IsNotNil(t, err)
info.Output = []byte("{invalid")
output, err = getOuthUserOutput(t, &info, info.Subject)
newClaims := testInfo{Output: []byte("{invalid")}
info.ClaimsSent = newClaims
output, err = getOuthUserOutput(t, info)
test.IsNotNil(t, err)
}
@@ -185,10 +261,10 @@ func redirectsToSite(input string) string {
return "other"
}
func getOuthUserOutput(t *testing.T, info OAuthUserInfo, infoSubject string) (string, error) {
func getOuthUserOutput(t *testing.T, info OAuthUserInfo) (string, error) {
t.Helper()
w := httptest.NewRecorder()
err := CheckOauthUserAndRedirect(info, infoSubject, w)
err := CheckOauthUserAndRedirect(info, w)
if err != nil {
return "", err
}

View File

@@ -45,8 +45,8 @@ func Init(baseUrl string, credentials models.AuthenticationConfig) {
}
// HandlerLogin is a handler for showing the login screen
func HandlerLogin(w http.ResponseWriter, r *http.Request) {
initLogin(w, r, false)
func HandlerLogin(w http.ResponseWriter, r *http.Request) { // If user clicked logout, force consent
initLogin(w, r, r.URL.Query().Has("consent"))
}
func initLogin(w http.ResponseWriter, r *http.Request, showConsentScreen bool) {
@@ -97,7 +97,12 @@ func HandlerCallback(w http.ResponseWriter, r *http.Request) {
showOauthErrorPage(w, r, "Failed to get userinfo: "+err.Error())
return
}
err = authentication.CheckOauthUserAndRedirect(userInfo, userInfo.Subject, w)
info := authentication.OAuthUserInfo{
Subject: userInfo.Subject,
Email: userInfo.Email,
ClaimsSent: userInfo,
}
err = authentication.CheckOauthUserAndRedirect(info, w)
if err != nil {
showOauthErrorPage(w, r, "Failed to extract scope value: "+err.Error())
}

View File

@@ -20,14 +20,14 @@ const cookieLifeAdmin = 30 * 24 * time.Hour
// IsValidSession checks if the user is submitting a valid session token
// If valid session is found, useSession will be called
// Returns true if authenticated, otherwise false
func IsValidSession(w http.ResponseWriter, r *http.Request) bool {
func IsValidSession(w http.ResponseWriter, r *http.Request, isOauth bool, OAuthRecheckInterval int) bool {
cookie, err := r.Cookie("session_token")
if err == nil {
sessionString := cookie.Value
if sessionString != "" {
session, ok := database.GetSession(sessionString)
if ok {
return useSession(w, sessionString, session)
return useSession(w, sessionString, session, isOauth, OAuthRecheckInterval)
}
}
}
@@ -38,13 +38,13 @@ func IsValidSession(w http.ResponseWriter, r *http.Request) bool {
// if it has // been used for more than an hour to limit session hijacking
// Returns true if session is still valid
// Returns false if session is invalid (and deletes it)
func useSession(w http.ResponseWriter, id string, session models.Session) bool {
func useSession(w http.ResponseWriter, id string, session models.Session, isOauth bool, OAuthRecheckInterval int) bool {
if session.ValidUntil < time.Now().Unix() {
database.DeleteSession(id)
return false
}
if session.RenewAt < time.Now().Unix() {
CreateSession(w)
CreateSession(w, isOauth, OAuthRecheckInterval)
database.DeleteSession(id)
}
return true
@@ -52,13 +52,18 @@ func useSession(w http.ResponseWriter, id string, session models.Session) bool {
// CreateSession creates a new session - called after login with correct username / password
// If sessions parameter is nil, it will be loaded from config
func CreateSession(w http.ResponseWriter) {
func CreateSession(w http.ResponseWriter, isOauth bool, OAuthRecheckInterval int) {
timeExpiry := time.Now().Add(cookieLifeAdmin)
if isOauth {
timeExpiry = time.Now().Add(time.Duration(OAuthRecheckInterval) * time.Hour)
}
sessionString := helper.GenerateRandomString(60)
database.SaveSession(sessionString, models.Session{
RenewAt: time.Now().Add(12 * time.Hour).Unix(),
ValidUntil: time.Now().Add(cookieLifeAdmin).Unix(),
ValidUntil: timeExpiry.Unix(),
})
writeSessionCookie(w, sessionString, time.Now().Add(cookieLifeAdmin))
writeSessionCookie(w, sessionString, timeExpiry)
}
// LogoutSession logs out user and deletes session

View File

@@ -8,7 +8,7 @@
<h2 class="card-title">Unauthorised user</h2>
<br>
<p class="card-text">Login with OAuth provider was sucessful, however this user is not authorised by Gokapi.</p><br><br>
<a href="./login" class="card-link">Log in as different user</a>
<a href="./login?consent=true" class="card-link">Log in as different user</a>
</div>
</div>
</div>

View File

@@ -20,7 +20,7 @@
{{ end}}
<p class="text-monospace">{{ .ErrorGenericMessage }}</blockquote></p><br>
{{ end }}
<a href="./login" class="card-link">Try again</a>
<a href="./login?consent=true" class="card-link">Try again</a>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
</main>
<footer class="mt-auto text-white-50">
<p> Powered by <a href="https://github.com/Forceu/Gokapi" target="_blank">Gokapi v{{template "version"}}</a></p>
<p> Powered by <a href="https://github.com/Forceu/Gokapi" target="_blank" rel="noopener noreferrer">Gokapi v{{template "version"}}</a></p>
</footer>
</div>
</body>