2017-02-14 04:54:11 +01:00
|
|
|
package basicauth
|
|
|
|
|
|
|
|
import (
|
2020-11-21 11:04:37 +01:00
|
|
|
stdContext "context"
|
2017-02-14 04:54:11 +01:00
|
|
|
"strconv"
|
2020-08-15 14:40:41 +02:00
|
|
|
"sync"
|
2017-02-14 04:54:11 +01:00
|
|
|
"time"
|
|
|
|
|
2019-10-25 00:27:02 +02:00
|
|
|
"github.com/kataras/iris/v12/context"
|
2020-11-21 11:04:37 +01:00
|
|
|
"github.com/kataras/iris/v12/sessions"
|
2017-02-14 04:54:11 +01:00
|
|
|
)
|
|
|
|
|
2020-04-28 04:42:23 +02:00
|
|
|
func init() {
|
2020-04-28 21:34:36 +02:00
|
|
|
context.SetHandlerName("iris/middleware/basicauth.*", "iris.basicauth")
|
2020-04-28 04:42:23 +02:00
|
|
|
}
|
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
const (
|
|
|
|
DefaultRealm = "Authorization Required"
|
|
|
|
DefaultMaxTriesCookie = "basicmaxtries"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
authorizationType = "Basic Authentication"
|
|
|
|
authenticateHeaderKey = "WWW-Authenticate"
|
|
|
|
proxyAuthenticateHeaderKey = "Proxy-Authenticate"
|
|
|
|
authorizationHeaderKey = "Authorization"
|
|
|
|
proxyAuthorizationHeaderKey = "Proxy-Authorization"
|
|
|
|
)
|
|
|
|
|
|
|
|
type AuthFunc func(ctx *context.Context, username, password string) (interface{}, bool)
|
|
|
|
|
|
|
|
type Options struct {
|
|
|
|
// Realm http://tools.ietf.org/html/rfc2617#section-1.2.
|
|
|
|
// E.g. "Authorization Required".
|
|
|
|
Realm string
|
|
|
|
// In the case of proxies, the challenging status code is 407 (Proxy Authentication Required),
|
|
|
|
// the Proxy-Authenticate response header contains at least one challenge applicable to the proxy,
|
|
|
|
// and the Proxy-Authorization request header is used for providing the credentials to the proxy server.
|
|
|
|
//
|
|
|
|
// Proxy should be used to gain access to a resource behind a proxy server.
|
|
|
|
// It authenticates the request to the proxy server, allowing it to transmit the request further.
|
|
|
|
Proxy bool
|
|
|
|
// Usage:
|
|
|
|
// - Allow: AllowUsers(iris.Map{"username": "...", "password": "...", "other_field": ...}, [BCRYPT])
|
|
|
|
// - Allow: AllowUsersFile("users.yml", [BCRYPT])
|
|
|
|
Allow AuthFunc
|
|
|
|
// If greater than zero then the server will send 403 forbidden status code afer MaxTries
|
|
|
|
// of invalid credentials of a specific client consumed (session or cookie based, see MaxTriesCookie).
|
|
|
|
// By default the server will re-ask for credentials on any amount of invalid credentials.
|
|
|
|
MaxTries int
|
|
|
|
// If a session manager is register under the current request,
|
|
|
|
// then this value should be the key of the session storage which
|
|
|
|
// the current tries will be stored. Otherwise
|
|
|
|
// it is the raw cookie name.
|
|
|
|
// The cookie is stored up to the configured MaxAge if greater than zero or for 1 year,
|
|
|
|
// so a forbidden client can request for authentication again after the MaxAge expired.
|
|
|
|
//
|
|
|
|
// Note that, the session way is recommended as the current tries
|
|
|
|
// cannot be modified by the client (unless the client removes the session cookie).
|
|
|
|
// However the raw cookie performs faster. You can always set custom logic
|
|
|
|
// on the Allow field as you have access to the current request Context.
|
|
|
|
// To set custom cookie options use the `Context.AddCookieOptions(options ...iris.CookieOption)`
|
|
|
|
// before the basic auth middleware.
|
|
|
|
//
|
|
|
|
// If MaxTries > 0 then it defaults to "basicmaxtries".
|
|
|
|
// The MaxTries should be set to greater than zero.
|
|
|
|
MaxTriesCookie string
|
|
|
|
// If not nil runs after 401 (or 407 if proxy is enabled) status code.
|
|
|
|
// Can be used to set custom response for unauthenticated clients.
|
|
|
|
OnAsk context.Handler
|
|
|
|
// If not nil runs after the 403 forbidden status code (when Allow returned false and MaxTries consumed).
|
|
|
|
// Can be used to set custom response when client tried to access a resource with invalid credentials.
|
|
|
|
OnForbidden context.Handler
|
|
|
|
// MaxAge sets expiration duration for the in-memory credentials map.
|
|
|
|
// By default an old map entry will be removed when the user visits a page.
|
|
|
|
// In order to remove old entries automatically please take a look at the `GC` option too.
|
|
|
|
//
|
|
|
|
// Usage:
|
|
|
|
// MaxAge: 30*time.Minute
|
|
|
|
MaxAge time.Duration
|
|
|
|
// GC automatically clears old entries every x duration.
|
|
|
|
// Note that, by old entries we mean expired credentials therefore
|
|
|
|
// the `MaxAge` option should be already set,
|
|
|
|
// if it's not then all entries will be removed on "every" duration.
|
|
|
|
// The standard context can be used for the internal ticker cancelation, it can be nil.
|
|
|
|
//
|
|
|
|
// Usage:
|
|
|
|
// GC: basicauth.GC{Every: 2*time.Hour}
|
|
|
|
GC GC
|
|
|
|
}
|
|
|
|
|
|
|
|
type GC struct {
|
|
|
|
Context stdContext.Context
|
|
|
|
Every time.Duration
|
|
|
|
}
|
|
|
|
|
|
|
|
// https://tools.ietf.org/html/rfc2617
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication
|
|
|
|
//
|
|
|
|
// As the user ID and password are passed over the network as clear text
|
|
|
|
// (it is base64 encoded, but base64 is a reversible encoding), the basic authentication scheme is not secure.
|
|
|
|
// HTTPS/TLS should be used with basic authentication. Without these additional security enhancements,
|
|
|
|
// basic authentication should not be used to protect sensitive or valuable information.
|
|
|
|
type BasicAuth struct {
|
|
|
|
opts Options
|
|
|
|
// built based on proxy field
|
|
|
|
askCode int
|
|
|
|
authorizationHeader string
|
|
|
|
authenticateHeader string
|
|
|
|
// built based on realm field.
|
|
|
|
authenticateHeaderValue string
|
2020-10-12 14:52:53 +02:00
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
credentials map[string]*time.Time // key = username:password, value = expiration time (if MaxAge > 0).
|
|
|
|
mu sync.RWMutex // protects the credentials as they can modified.
|
|
|
|
}
|
|
|
|
|
|
|
|
func New(opts Options) context.Handler {
|
|
|
|
var (
|
|
|
|
askCode = 401
|
|
|
|
authorizationHeader = authorizationHeaderKey
|
|
|
|
authenticateHeader = authenticateHeaderKey
|
|
|
|
authenticateHeaderValue = "Basic"
|
|
|
|
)
|
|
|
|
|
|
|
|
if opts.Allow == nil {
|
|
|
|
panic("BasicAuth: Allow field is required")
|
2017-02-14 04:54:11 +01:00
|
|
|
}
|
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
if opts.Realm != "" {
|
|
|
|
authenticateHeaderValue += " realm=" + strconv.Quote(opts.Realm)
|
|
|
|
}
|
2018-08-06 03:20:59 +02:00
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
if opts.Proxy {
|
|
|
|
askCode = 407
|
|
|
|
authenticateHeader = proxyAuthenticateHeaderKey
|
|
|
|
authorizationHeader = proxyAuthorizationHeaderKey
|
2017-02-14 04:54:11 +01:00
|
|
|
}
|
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
if opts.MaxTries > 0 && opts.MaxTriesCookie == "" {
|
|
|
|
opts.MaxTriesCookie = DefaultMaxTriesCookie
|
|
|
|
}
|
2017-02-14 04:54:11 +01:00
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
b := &BasicAuth{
|
|
|
|
opts: opts,
|
|
|
|
askCode: askCode,
|
|
|
|
authorizationHeader: authorizationHeader,
|
|
|
|
authenticateHeader: authenticateHeader,
|
|
|
|
authenticateHeaderValue: authenticateHeaderValue,
|
|
|
|
credentials: make(map[string]*time.Time),
|
|
|
|
}
|
|
|
|
|
|
|
|
if opts.GC.Every > 0 {
|
|
|
|
go b.runGC(opts.GC.Context, opts.GC.Every)
|
|
|
|
}
|
|
|
|
|
|
|
|
return b.serveHTTP
|
|
|
|
}
|
|
|
|
|
|
|
|
// - map[string]string form of: {username:password, ...} form.
|
|
|
|
// - map[string]interface{} form of: []{"username": "...", "password": "...", "other_field": ...}, ...}.
|
|
|
|
// - []T which T completes the User interface.
|
|
|
|
// - []T which T contains at least Username and Password fields.
|
|
|
|
func Default(users interface{}, userOpts ...UserAuthOption) context.Handler {
|
|
|
|
opts := Options{
|
|
|
|
Realm: DefaultRealm,
|
|
|
|
Allow: AllowUsers(users, userOpts...),
|
|
|
|
}
|
|
|
|
return New(opts)
|
|
|
|
}
|
|
|
|
|
|
|
|
func Load(jsonOrYamlFilename string, userOpts ...UserAuthOption) context.Handler {
|
|
|
|
opts := Options{
|
|
|
|
Realm: DefaultRealm,
|
|
|
|
Allow: AllowUsersFile(jsonOrYamlFilename, userOpts...),
|
|
|
|
}
|
|
|
|
return New(opts)
|
|
|
|
}
|
|
|
|
|
|
|
|
// askForCredentials sends a response to the client which client should catch
|
|
|
|
// and ask for username:password credentials.
|
|
|
|
func (b *BasicAuth) askForCredentials(ctx *context.Context) {
|
|
|
|
ctx.Header(b.authenticateHeader, b.authenticateHeaderValue)
|
|
|
|
ctx.StopWithStatus(b.askCode)
|
|
|
|
|
|
|
|
if h := b.opts.OnAsk; h != nil {
|
|
|
|
h(ctx)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// If a (proxy) server receives valid credentials that are inadequate to access a given resource,
|
|
|
|
// the server should respond with the 403 Forbidden status code.
|
|
|
|
// Unlike 401 Unauthorized or 407 Proxy Authentication Required, authentication is impossible for this user.
|
|
|
|
func (b *BasicAuth) forbidden(ctx *context.Context) {
|
|
|
|
ctx.StopWithStatus(403)
|
|
|
|
|
|
|
|
if h := b.opts.OnForbidden; h != nil {
|
|
|
|
h(ctx)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (b *BasicAuth) getCurrentTries(ctx *context.Context) (tries int) {
|
|
|
|
sess := sessions.Get(ctx)
|
|
|
|
if sess != nil {
|
|
|
|
tries = sess.GetIntDefault(b.opts.MaxTriesCookie, 0)
|
|
|
|
} else {
|
|
|
|
if v := ctx.GetCookie(b.opts.MaxTriesCookie); v != "" {
|
|
|
|
tries, _ = strconv.Atoi(v)
|
2017-02-14 04:54:11 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
return
|
2017-02-14 04:54:11 +01:00
|
|
|
}
|
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
func (b *BasicAuth) setCurrentTries(ctx *context.Context, tries int) {
|
|
|
|
sess := sessions.Get(ctx)
|
|
|
|
if sess != nil {
|
|
|
|
sess.Set(b.opts.MaxTriesCookie, tries)
|
|
|
|
} else {
|
|
|
|
maxAge := b.opts.MaxAge
|
|
|
|
if maxAge == 0 {
|
|
|
|
maxAge = context.SetCookieKVExpiration // 1 year.
|
|
|
|
}
|
|
|
|
ctx.SetCookieKV(b.opts.MaxTriesCookie, strconv.Itoa(tries), context.CookieExpires(maxAge))
|
2018-08-06 03:20:59 +02:00
|
|
|
}
|
2017-02-14 04:54:11 +01:00
|
|
|
}
|
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
func (b *BasicAuth) resetCurrentTries(ctx *context.Context) {
|
|
|
|
sess := sessions.Get(ctx)
|
|
|
|
if sess != nil {
|
|
|
|
sess.Delete(b.opts.MaxTriesCookie)
|
|
|
|
} else {
|
|
|
|
ctx.RemoveCookie(b.opts.MaxTriesCookie)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// serveHTTP is the main method of this middleware,
|
|
|
|
// checks and verifies the auhorization header for basic authentication,
|
|
|
|
// next handlers will only be executed when the client is allowed to continue.
|
|
|
|
func (b *BasicAuth) serveHTTP(ctx *context.Context) {
|
|
|
|
header := ctx.GetHeader(b.authorizationHeader)
|
|
|
|
fullUser, username, password, ok := decodeHeader(header)
|
|
|
|
if !ok { // Header is malformed or missing.
|
|
|
|
b.askForCredentials(ctx)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
maxTries = b.opts.MaxTries
|
|
|
|
tries int
|
|
|
|
)
|
|
|
|
|
|
|
|
if maxTries > 0 {
|
|
|
|
tries = b.getCurrentTries(ctx)
|
|
|
|
}
|
|
|
|
|
|
|
|
user, ok := b.opts.Allow(ctx, username, password)
|
|
|
|
if !ok { // This username:password combination was not allowed.
|
|
|
|
if maxTries > 0 {
|
|
|
|
tries++
|
|
|
|
b.setCurrentTries(ctx, tries)
|
|
|
|
if tries >= maxTries { // e.g. if MaxTries == 1 then it should be allowed only once, so we must send forbidden now.
|
|
|
|
b.forbidden(ctx) // a user was forbidden, to reset its status should clear the Authorization header and cookie and request the resource again.
|
|
|
|
return
|
|
|
|
}
|
2020-10-12 01:07:04 +02:00
|
|
|
}
|
|
|
|
|
2017-02-14 04:54:11 +01:00
|
|
|
b.askForCredentials(ctx)
|
2017-06-10 14:28:09 +02:00
|
|
|
return
|
|
|
|
}
|
2020-10-12 01:07:04 +02:00
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
if tries > 0 {
|
|
|
|
// had failures but it's ok, reset the tries on success.
|
|
|
|
b.resetCurrentTries(ctx)
|
2020-10-12 14:52:53 +02:00
|
|
|
}
|
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
b.mu.RLock()
|
|
|
|
expiresAt, ok := b.credentials[fullUser]
|
|
|
|
b.mu.RUnlock()
|
|
|
|
var authorizedAt time.Time
|
|
|
|
if ok {
|
|
|
|
if expiresAt != nil { // Has expiration.
|
|
|
|
if expiresAt.Before(time.Now()) { // Has been expired.
|
|
|
|
b.mu.Lock() // Delete the entry.
|
|
|
|
delete(b.credentials, fullUser)
|
|
|
|
b.mu.Unlock()
|
|
|
|
// Re-ask for new credentials.
|
|
|
|
b.askForCredentials(ctx)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// It's ok, find the time authorized to fill the user below, if necessary.
|
|
|
|
authorizedAt = expiresAt.Add(-b.opts.MaxAge)
|
2017-06-10 14:28:09 +02:00
|
|
|
}
|
2020-11-21 11:04:37 +01:00
|
|
|
} else {
|
|
|
|
// Saved credential not found, first login.
|
|
|
|
if b.opts.MaxAge > 0 { // Expiration is enabled, set the value.
|
|
|
|
authorizedAt = time.Now()
|
|
|
|
t := authorizedAt.Add(b.opts.MaxAge)
|
|
|
|
expiresAt = &t
|
|
|
|
}
|
|
|
|
b.mu.Lock()
|
|
|
|
b.credentials[fullUser] = expiresAt
|
|
|
|
b.mu.Unlock()
|
|
|
|
}
|
2017-02-14 04:54:11 +01:00
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
if user == nil {
|
|
|
|
// No custom uset was set by the auth func,
|
|
|
|
// it is passed though, set a simple user here:
|
|
|
|
user = &context.SimpleUser{
|
|
|
|
Authorization: authorizationType,
|
|
|
|
AuthorizedAt: authorizedAt,
|
|
|
|
Username: username,
|
|
|
|
Password: password,
|
2017-02-14 04:54:11 +01:00
|
|
|
}
|
|
|
|
}
|
2020-10-12 01:07:04 +02:00
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
ctx.SetUser(user)
|
|
|
|
ctx.SetLogoutFunc(b.logout)
|
2020-10-12 14:52:53 +02:00
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
ctx.Next()
|
|
|
|
}
|
|
|
|
|
|
|
|
// logout clears the current user's credentials.
|
|
|
|
func (b *BasicAuth) logout(ctx *context.Context) {
|
|
|
|
var (
|
|
|
|
fullUser, username, password string
|
|
|
|
ok bool
|
|
|
|
)
|
|
|
|
|
|
|
|
if u := ctx.User(); u != nil { // Get the saved ones, if any.
|
|
|
|
username, _ = u.GetUsername()
|
|
|
|
password, _ = u.GetPassword()
|
|
|
|
fullUser = username + colonLiteral + password
|
|
|
|
ok = username != "" && password != ""
|
|
|
|
}
|
|
|
|
|
|
|
|
if !ok {
|
|
|
|
// If the custom user does
|
|
|
|
// not implement those two, then extract from the request header:
|
|
|
|
header := ctx.GetHeader(b.authorizationHeader)
|
|
|
|
fullUser, username, password, ok = decodeHeader(header)
|
|
|
|
}
|
|
|
|
|
|
|
|
if ok { // If it's authorized then try to lock and delete.
|
|
|
|
if b.opts.Proxy {
|
|
|
|
ctx.Request().Header.Del(proxyAuthorizationHeaderKey)
|
2020-10-12 14:52:53 +02:00
|
|
|
}
|
2020-11-21 11:04:37 +01:00
|
|
|
// delete the request header so future Request().BasicAuth are empty.
|
|
|
|
ctx.Request().Header.Del(authorizationHeaderKey)
|
|
|
|
|
|
|
|
b.mu.Lock()
|
|
|
|
delete(b.credentials, fullUser)
|
|
|
|
b.mu.Unlock()
|
2020-10-12 01:07:04 +02:00
|
|
|
}
|
2020-11-21 11:04:37 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// runGC runs a function in a separate go routine
|
|
|
|
// every x duration to clear in-memory expired credential entries.
|
|
|
|
func (b *BasicAuth) runGC(ctx stdContext.Context, every time.Duration) {
|
|
|
|
if ctx == nil {
|
|
|
|
ctx = stdContext.Background()
|
|
|
|
}
|
|
|
|
|
|
|
|
t := time.NewTicker(every)
|
|
|
|
defer t.Stop()
|
2020-10-12 01:07:04 +02:00
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
case <-t.C:
|
|
|
|
b.gc()
|
|
|
|
}
|
|
|
|
}
|
2017-02-14 04:54:11 +01:00
|
|
|
}
|
2020-10-12 01:07:04 +02:00
|
|
|
|
2020-11-21 11:04:37 +01:00
|
|
|
// gc removes all entries expired based on the max age or all entries (if max age is missing).
|
|
|
|
func (b *BasicAuth) gc() int {
|
|
|
|
now := time.Now()
|
|
|
|
var markedForDeletion []string
|
|
|
|
|
|
|
|
b.mu.RLock()
|
|
|
|
for fullUser, expiresAt := range b.credentials {
|
|
|
|
if expiresAt == nil {
|
|
|
|
markedForDeletion = append(markedForDeletion, fullUser)
|
|
|
|
} else if expiresAt.Before(now) {
|
|
|
|
markedForDeletion = append(markedForDeletion, fullUser)
|
|
|
|
}
|
2020-10-12 01:07:04 +02:00
|
|
|
}
|
2020-11-21 11:04:37 +01:00
|
|
|
b.mu.RUnlock()
|
|
|
|
|
|
|
|
n := len(markedForDeletion)
|
|
|
|
if n > 0 {
|
|
|
|
for _, fullUser := range markedForDeletion {
|
|
|
|
b.mu.Lock()
|
|
|
|
delete(b.credentials, fullUser)
|
|
|
|
b.mu.Unlock()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return n
|
2020-10-12 01:07:04 +02:00
|
|
|
}
|