Skip to content

Commit

Permalink
Merged in traefik-forwardauth (pull request #46)
Browse files Browse the repository at this point in the history
Traefik forwardauth

* Allow vouch to forward to authentication for a traefik forwardauth scheme

* Clean up code, fix API url

* Docs and cleanup

* Vouch and session tests

* Vouch docs

* Test userid header

* fix image
  • Loading branch information
zix99 committed Dec 18, 2021
1 parent e9ff53d commit 5e8e2be
Show file tree
Hide file tree
Showing 10 changed files with 251 additions and 23 deletions.
2 changes: 1 addition & 1 deletion docs/.vuepress/config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const repo = 'zix99/simple-auth';
const docsUrl = "https://simple-auth.surge.sh";
const docsUrl = "https://simple-auth.zdyn.net";
const repoUrl = `https://github.com/${repo}`;
const fileUrl = `${repoUrl}/tree/master`

Expand Down
12 changes: 11 additions & 1 deletion docs/authenticators/vouch.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Vouch API

The vouch API allows a down-stream user to check if the request is pre-authenticated (meaning the user has a session). This will only work
from same-domain site. It is intended to be used with [nginx auth_request](../cookbooks/nginx-auth-request), but may suite other use-cases.
from same-domain site. It is intended to be used with [nginx auth_request](../cookbooks/nginx-auth-request) or [traefik forwardauth](../cookbooks/traefik.md),
but may suite other use-cases.

## Enabling

Expand All @@ -15,6 +16,15 @@ authenticators:
From the same-domain (making sure cookies are passed) make a **GET** request to `/api/v1/auth/vouch`. It will return 200 on success, otherwise 401

## Forward Auth

By default, vouch returns a `401` if authentication doesn't exist. Enabling *forward* will instead return a temporary redirect via a `307` to the authentication
portal. It checks for a `continue=` query param or the `X-Forwarded` headers traefik provides to send the user back to their source. These continue
urls must be allow-listed via `web.login.settings.allowedcontinueurls`.

Similar to the vouch, this is a simple **GET** request to `/api/v1/auth/vouch?forward=1`

## See Also

- [nginx auth_request](/cookbooks/nginx-auth-request.md) - Using vouch with nginx
- [traefik forwardauth](/cookbooks/traefik.md) - Using vouch with traefik
41 changes: 31 additions & 10 deletions docs/cookbooks/traefik.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,35 @@

[Traefik](https://traefik.io/) has become a popular choice for load-balancing docker applications.

This strategy uses [same-domain cookie auth](/access/cookie.md) to authenticate the user.
::: danger
This is an example config, and doesn't have SSL enabled by default. [Traefik supports SSL](https://doc.traefik.io/traefik/https/overview/).
Make sure to enable it so that username and password are encrypted in transit!
:::

In both cases, we use a same-domain cookie sharing technique, described [here](/access/cookie)

## Forward Auth

This strategy is similar to [nginx auth_request](nginx-auth-request.md), where traefik will forward the request
to *simple-auth*'s `vouch` endpoint to see if a user has a session (in this case, stored in a cookie). Unlike
nginx's `auth_request`, the user should be forwarded to *simple-auth* by the `vouch` endpoint if there is no valid session.

This can also be used via traefik to forward a header, eg the user id, to the downstream service.

<mermaid>
graph LR
A[Traefik]
A -- ForwardAuth--> C[Simple-auth]
A -- example.com --> B[App]
</mermaid>

### docker-compose

<<< @/examples/traefik-forwardauth/docker-compose.yml

## Same-Domain Cookie in App

This strategy uses [same-domain cookie auth](/access/cookie.md) to authenticate the user inside the app, with no special load-balancer setup.

Here, we're using traefik to have both *simple-auth* and a *testapp* (validates the token in the cookie). The test-app will forward to `auth.${DOMAIN}` if it doesn't detect an `auth` token.

Expand All @@ -13,19 +41,12 @@ A -- example.com --> B[Test App]
A -- auth.example.com --> C[Simple-auth]
</mermaid>

In this case, we use a same-domain cookie sharing technique, described [here](/access/cookie)

::: danger
This is an example config, and doesn't have SSL enabled by default. [Traefik supports SSL](https://doc.traefik.io/traefik/https/overview/).
Make sure to enable it so that username and password are encrypted in transit!
:::

## docker-compose
### docker-compose

<<< @/examples/traefik-subdomain/docker-compose.yml


## Test App
### Test App

::: tip
You can find more information about the testapp [here](/cookbooks/decodejwt)
Expand Down
56 changes: 56 additions & 0 deletions docs/examples/traefik-forwardauth/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
version: '3.3'
services:

# Traefik listening on port 88 (in case 80 conflicts with something...)
traefik:
image: traefik:v2.3
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:88"
ports:
- "88:88"
- "8090:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"

# Simpleauth
simpleauth:
image: zix99/simple-auth:latest
environment:
SA_WEB_LOGIN_COOKIE_JWT_SIGNINGKEY: a-unqiue-signing-key # CHANGE ME!!
SA_VERBOSE: 'true'
SA_WEB_LOGIN_SETTINGS_ROUTEONLOGIN: "http://${DOMAIN}:88"
# Allow login to send user back to any subdomain
SA_WEB_LOGIN_SETTINGS_ALLOWEDCONTINUEURLS: 'https?://.*${DOMAIN}(:\d+)?/.*'
SA_WEB_LOGIN_COOKIE_DOMAIN: ${DOMAIN} # IMPORTANT: Higher-level domain
SA_WEB_BASEURL: http://auth.${DOMAIN}:88
SA_AUTHENTICATORS_VOUCH_ENABLED: 'true'
# The vouch endpoint will put the user's UUID on this header to be forwarded
SA_AUTHENTICATORS_VOUCH_USERHEADER: 'X-User-Id'
volumes:
- sadb:/var/lib/simple-auth
labels:
- "traefik.enable=true"
- "traefik.http.routers.simpleauth.rule=Host(`auth.${DOMAIN}`)" # Fill in with your own domain
- "traefik.http.routers.simpleauth.entrypoints=web"
# Set up middleware, needs to be the internal URL of the service so that the continue URL is created correctly
# Will be used by other services
- "traefik.http.middlewares.simple-auth.forwardauth.address=http://simpleauth/api/v1/auth/vouch?forward=1"
- "traefik.http.middlewares.simple-auth.forwardauth.authResponseHeaders=X-User-Id" # Forward this header to the end service

# testapp is a small nodejs app that will verify your token and only let you in if you have a valid token
testapp:
image: nginx:latest
labels:
- "traefik.enable=true"
# App settings
- "traefik.http.routers.testapp.rule=Host(`${DOMAIN}`)" # Fill in with your own domain
- "traefik.http.routers.testapp.entrypoints=web"
# Attach to the simple-auth middleware defined in simple-auth
- "traefik.http.routers.testapp.middlewares=simple-auth"


volumes:
sadb: {}
3 changes: 2 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ type (
}

ConfigVouchAuthenticator struct {
Enabled bool
Enabled bool
UserHeader string // If non-empty, will respond with the user's ID in a header
}

// ConfigOAuth2Client contains specific client settings
Expand Down
2 changes: 1 addition & 1 deletion pkg/routes/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func MountAPI(e *echo.Group, config *config.Config, db db.SADB) {
}
if config.Authenticators.Vouch.Enabled {
route := v1api.Group("/auth/vouch")
authAPI.NewVouchAuthController(db, &config.Authenticators.Vouch, &config.Web.Login.Cookie).Mount(route)
authAPI.NewVouchAuthController(db, &config.Authenticators.Vouch, &config.Web.Login.Cookie, config.Web.GetBaseURL()).Mount(route)
}
{
v1api.GET("/auth/oauth2/client/:client_id", oAuthController.RouteClientInfo)
Expand Down
65 changes: 61 additions & 4 deletions pkg/routes/api/auth/vouch.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package auth

import (
"net/http"
"net/url"
"simple-auth/pkg/appcontext"
"simple-auth/pkg/config"
"simple-auth/pkg/db"
"simple-auth/pkg/routes/common"
"simple-auth/pkg/routes/middleware/selector"
"simple-auth/pkg/routes/middleware/selector/auth"

"github.com/labstack/echo/v4"
Expand All @@ -14,33 +18,86 @@ type VouchAuthController struct {
db db.SADB
config *config.ConfigVouchAuthenticator
cookieConfig *config.ConfigLoginCookie
baseUrl string
}

func NewVouchAuthController(db db.SADB, config *config.ConfigVouchAuthenticator, cookieConfig *config.ConfigLoginCookie) *VouchAuthController {
func NewVouchAuthController(db db.SADB, config *config.ConfigVouchAuthenticator, cookieConfig *config.ConfigLoginCookie, baseUrl string) *VouchAuthController {
return &VouchAuthController{
db: db,
config: config,
cookieConfig: cookieConfig,
baseUrl: baseUrl,
}
}

func (env *VouchAuthController) Mount(group *echo.Group) {
logrus.Info("Enabling vouch auth...")
loggedInMiddleware := auth.NewAuthMiddleware(
auth.NewSessionAuthHandler(env.cookieConfig),
loggedInMiddleware := selector.NewSelectorMiddleware(
auth.NewSessionAuthProvider(env.cookieConfig),
env.authRedirectIfNeeded,
selector.HandlerUnauthorized(),
)
group.GET("", env.routeVouchAuth, loggedInMiddleware)
}

// Resolves URL assuming forward headers (As defined in traefik)
func resolveContinueUrl(c echo.Context) string {
if continueQuery := c.QueryParam("continue"); continueQuery != "" {
return continueQuery
}

headers := c.Request().Header
if host := headers.Get("X-Forwarded-Host"); host != "" {
proto := headers.Get("X-Forwarded-Proto")
if proto == "" {
proto = "http"
}
return proto + "://" + host + headers.Get("X-Forwarded-Uri")
}
return ""
}

// If redirect is requested, send the user to authentication and try to return to the source
func (env *VouchAuthController) authRedirectIfNeeded(next echo.HandlerFunc, c echo.Context) (bool, error) {
log := appcontext.GetLogger(c)

if c.QueryParam("forward") != "" {
continueUrl := resolveContinueUrl(c)

if baseUrl, err := url.Parse(env.baseUrl); err == nil {
if continueUrl != "" {
qp := baseUrl.Query()
qp.Set("continue", continueUrl)
baseUrl.RawQuery = qp.Encode()
}
return true, c.Redirect(http.StatusTemporaryRedirect, baseUrl.String())
} else {
log.Errorf("Error parsing forward url during vouch: %v", err)
return true, c.Redirect(http.StatusTemporaryRedirect, env.baseUrl)
}
}

return false, nil
}

// @Summary Vouch
// @Description A vouch endpoint that checks if the user is logged in via cookie. Intended to be used as `auth_request` in nginx
// @Description A vouch endpoint that checks if the user is logged in via cookie. Intended to be used as `auth_request` in nginx for forwardauth in traefik
// @Tags Auth
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param forward query boolean false "If true, will forward to login with a 307 rather than return a 401"
// @Param continue query string false "Will override X-Forward headers to set the continue URL. Must follow allowedContinueURL settings"
// @Success 200 {object} common.OKResponse
// @Failure 307,401 {object} common.ErrorResponse
// @Router /auth/vouch [get]
func (env *VouchAuthController) routeVouchAuth(c echo.Context) error {
incAuthCounterSuccess("vouch")

if env.config.UserHeader != "" {
accountUUID := auth.MustGetAccountUUID(c)
c.Response().Header().Set(env.config.UserHeader, accountUUID)
}

return common.HttpOK(c)
}
1 change: 1 addition & 0 deletions simpleauth.default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ authenticators:
sharedsecret: null
vouch: # An endpoint intended to use for nginx auth_request
enabled: false
userheader: "" # If non-empty, will set the user's ID in the header with the given name
oauth2:
webgrant: true # Whether to allow web-grant (UI) or not
settings:
Expand Down
91 changes: 86 additions & 5 deletions tests/api/v1/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@ describe('auth-endpoints', () => {
},
validateStatus: () => true,
};
const sharedKeyOpts = {
headers: {
Authorization: `SharedKey ${config.apiSharedKey}`,
},
};

let testUser = null;
before(() => {
return http.post('/api/v1/account', {
username: 'authtest',
password: 'test-pass',
email: 'tps2@example.com',
}, {
headers: {
Authorization: `SharedKey ${config.apiSharedKey}`,
},
}).then((resp) => {
}, sharedKeyOpts).then((resp) => {
testUser = resp.data;
});
});
Expand Down Expand Up @@ -55,11 +56,91 @@ describe('auth-endpoints', () => {
});

describe('#vouch', () => {
let sessionCookie = null;
it('Should allow creation of a session', () => {
const payload = { username: 'tps2@example.com', password: 'test-pass' };
return http.post('/api/v1/auth/session', payload, sharedKeyOpts) // shared key gets around CSRF
.then((resp) => {
assert.equal(resp.status, 200);
const cookies = resp.headers['set-cookie'][0];
[sessionCookie] = cookies.split(';');
assert.isNotEmpty(sessionCookie);
});
});

it('Should return 401 if error logging in', () => {
return http.get('/api/v1/auth/vouch', defaultOpts)
.then((resp) => {
assert.equal(resp.status, 401);
});
});

it('Should allow forwarding vouch', () => {
const opts = {
maxRedirects: 0,
validateStatus: () => true,
};
return http.get('/api/v1/auth/vouch?forward=1', opts)
.then((resp) => {
assert.equal(resp.status, 307);
});
});

it('Should allow forwarding vouch with continue', () => {
const opts = {
maxRedirects: 0,
validateStatus: () => true,
params: {
forward: '1',
continue: 'http://asdf.com',
},
};
return http.get('/api/v1/auth/vouch?forward=1', opts)
.then((resp) => {
assert.equal(resp.status, 307);
assert.include(resp.headers.location, '?continue=http%3A%2F%2Fasdf.com');
});
});

it('Should allow forwarding vouch with continue', () => {
const opts = {
maxRedirects: 0,
validateStatus: () => true,
params: {
forward: '1',
},
headers: {
'X-Forwarded-Host': 'asdf.com',
'X-Forwarded-Proto': 'http',
'X-Forwarded-Uri': '/abc',
},
};
return http.get('/api/v1/auth/vouch?forward=1', opts)
.then((resp) => {
assert.equal(resp.status, 307);
assert.include(resp.headers.location, '?continue=http%3A%2F%2Fasdf.com%2Fabc');
});
});

it('Should allow vouching with a session cookie', () => {
const headers = {
cookie: sessionCookie,
};
return http.get('/api/v1/auth/vouch', { headers })
.then((resp) => {
assert.equal(resp.status, 200);
assert.isNotEmpty(resp.headers['x-user-id']);
});
});

it('Should allow forwarding vouch with a session cookie', () => {
const headers = {
cookie: sessionCookie,
};
return http.get('/api/v1/auth/vouch?forward=1', { headers })
.then((resp) => {
assert.equal(resp.status, 200);
});
});
});
});
Loading

0 comments on commit 5e8e2be

Please sign in to comment.