From 11e21150d01dc529f7c6528216d6bccf5621b188 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Tue, 24 Nov 2020 14:58:02 +0200 Subject: [PATCH] more features and fix database/mysql:jwt example --- HISTORY.md | 2 +- _examples/README.md | 6 +- _examples/auth/basicauth/{ => basic}/main.go | 8 +- .../auth/basicauth/{ => basic}/main_test.go | 2 +- _examples/auth/basicauth/database/Dockerfile | 18 + _examples/auth/basicauth/database/README.md | 44 +++ .../basicauth/database/docker-compose.yml | 32 ++ _examples/auth/basicauth/database/go.mod | 8 + _examples/auth/basicauth/database/main.go | 113 +++++++ .../auth/basicauth/database/migration/db.sql | 22 ++ .../auth/basicauth/users_file_bcrypt/main.go | 30 ++ .../basicauth/users_file_bcrypt/users.yml | 12 + _examples/auth/basicauth/users_list/main.go | 58 ++++ _examples/database/mysql/README.md | 4 +- _examples/database/mysql/api/api.go | 29 +- _examples/database/mysql/go.mod | 2 +- _examples/database/mysql/main.go | 2 + context/context.go | 1 + context/context_user.go | 22 +- middleware/basicauth/basicauth.go | 320 ++++++++++++------ middleware/basicauth/basicauth_test.go | 2 +- middleware/basicauth/error.go | 108 ++++++ .../basicauth/{user_auth.go => user.go} | 70 +++- .../{user_auth_test.go => user_test.go} | 5 +- 24 files changed, 767 insertions(+), 153 deletions(-) rename _examples/auth/basicauth/{ => basic}/main.go (92%) rename _examples/auth/basicauth/{ => basic}/main_test.go (95%) create mode 100644 _examples/auth/basicauth/database/Dockerfile create mode 100644 _examples/auth/basicauth/database/README.md create mode 100644 _examples/auth/basicauth/database/docker-compose.yml create mode 100644 _examples/auth/basicauth/database/go.mod create mode 100644 _examples/auth/basicauth/database/main.go create mode 100644 _examples/auth/basicauth/database/migration/db.sql create mode 100644 _examples/auth/basicauth/users_file_bcrypt/main.go create mode 100644 _examples/auth/basicauth/users_file_bcrypt/users.yml create mode 100644 _examples/auth/basicauth/users_list/main.go create mode 100644 middleware/basicauth/error.go rename middleware/basicauth/{user_auth.go => user.go} (71%) rename middleware/basicauth/{user_auth_test.go => user_test.go} (97%) diff --git a/HISTORY.md b/HISTORY.md index 951dd8f5..6a333341 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -30,7 +30,7 @@ The codebase for Dependency Injection, Internationalization and localization and - Add `iris.DirOptions.SPA bool` field to allow [Single Page Applications](https://github.com/kataras/iris/tree/master/_examples/file-server/single-page-application/basic/main.go) under a file server. - A generic User interface, see the `Context.SetUser/User` methods in the New Context Methods section for more. In-short, the basicauth middleware's stored user can now be retrieved through `Context.User()` which provides more information than the native `ctx.Request().BasicAuth()` method one. Third-party authentication middleware creators can benefit of these two methods, plus the Logout below. -- A `Context.Logout` method is added, can be used to invalidate [basicauth](https://github.com/kataras/iris/blob/master/_examples/auth/basicauth/main.go) or [jwt](https://github.com/kataras/iris/blob/master/_examples/auth/jwt/blocklist/main.go) client credentials. +- A `Context.Logout` method is added, can be used to invalidate [basicauth](https://github.com/kataras/iris/blob/master/_examples/auth/basicauth/basic/main.go) or [jwt](https://github.com/kataras/iris/blob/master/_examples/auth/jwt/blocklist/main.go) client credentials. - Add the ability to [share functions](https://github.com/kataras/iris/tree/master/_examples/routing/writing-a-middleware/share-funcs) between handlers chain and add an [example](https://github.com/kataras/iris/tree/master/_examples/routing/writing-a-middleware/share-services) on sharing Go structures (aka services). - Add the new `Party.UseOnce` method to the `*Route` diff --git a/_examples/README.md b/_examples/README.md index ec3eaea7..8cac0ad2 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -196,7 +196,11 @@ * [Ttemplates and Functions](i18n/template) * [Pluralization and Variables](i18n/plurals) * Authentication, Authorization & Bot Detection - * [Basic Authentication](auth/basicauth/main.go) + * Basic Authentication + * [Basic](auth/basicauth/basic) + * [Load from a slice of Users](auth/basicauth/users_list) + * [Load from a file & encrypted passwords](auth/basicauth/users_file_bcrypt) + * [Fetch & validate a User from a Database (MySQL)](auth/basicauth/database) * [CORS](auth/cors) * JSON Web Tokens * [Basic](auth/jwt/basic/main.go) diff --git a/_examples/auth/basicauth/main.go b/_examples/auth/basicauth/basic/main.go similarity index 92% rename from _examples/auth/basicauth/main.go rename to _examples/auth/basicauth/basic/main.go index 4f06383e..12406578 100644 --- a/_examples/auth/basicauth/main.go +++ b/_examples/auth/basicauth/basic/main.go @@ -44,12 +44,12 @@ func newApp() *iris.Application { needAuth := app.Party("/admin", auth) { //http://localhost:8080/admin - needAuth.Get("/", h) + needAuth.Get("/", handler) // http://localhost:8080/admin/profile - needAuth.Get("/profile", h) + needAuth.Get("/profile", handler) // http://localhost:8080/admin/settings - needAuth.Get("/settings", h) + needAuth.Get("/settings", handler) needAuth.Get("/logout", logout) } @@ -63,7 +63,7 @@ func main() { app.Listen(":8080") } -func h(ctx iris.Context) { +func handler(ctx iris.Context) { // username, password, _ := ctx.Request().BasicAuth() // third parameter it will be always true because the middleware // makes sure for that, otherwise this handler will not be executed. diff --git a/_examples/auth/basicauth/main_test.go b/_examples/auth/basicauth/basic/main_test.go similarity index 95% rename from _examples/auth/basicauth/main_test.go rename to _examples/auth/basicauth/basic/main_test.go index 7a2142e2..e3f27486 100644 --- a/_examples/auth/basicauth/main_test.go +++ b/_examples/auth/basicauth/basic/main_test.go @@ -25,5 +25,5 @@ func TestBasicAuth(t *testing.T) { // with invalid basic auth e.GET("/admin/settings").WithBasicAuth("invalidusername", "invalidpassword"). - Expect().Status(httptest.StatusForbidden) + Expect().Status(httptest.StatusUnauthorized) } diff --git a/_examples/auth/basicauth/database/Dockerfile b/_examples/auth/basicauth/database/Dockerfile new file mode 100644 index 00000000..dd88c66c --- /dev/null +++ b/_examples/auth/basicauth/database/Dockerfile @@ -0,0 +1,18 @@ +# docker build -t myapp . +# docker run --rm -it -p 8080:8080 myapp:latest +FROM golang:latest AS builder +RUN apt-get update +ENV GO111MODULE=on \ + CGO_ENABLED=0 \ + GOOS=linux \ + GOARCH=amd64 +WORKDIR /go/src/app +COPY go.mod . +RUN go mod download +# cache step +COPY . . +RUN go install + +FROM scratch +COPY --from=builder /go/bin/myapp . +ENTRYPOINT ["./myapp"] \ No newline at end of file diff --git a/_examples/auth/basicauth/database/README.md b/_examples/auth/basicauth/database/README.md new file mode 100644 index 00000000..8cdbe94f --- /dev/null +++ b/_examples/auth/basicauth/database/README.md @@ -0,0 +1,44 @@ +# BasicAuth + MySQL & Docker Example + +## ⚡ Get Started + +Download the folder. + +### Install (Docker) + +Install [Docker](https://www.docker.com/) and execute the command below + +```sh +$ docker-compose up --build +``` + +### Install (Manually) + +Run `go build` or `go run main.go` and read below. + +#### MySQL + +Environment variables: + +```sh +MYSQL_USER=user_myapp +MYSQL_PASSWORD=dbpassword +MYSQL_HOST=localhost +MYSQL_DATABASE=myapp +``` + +Download the schema from [migration/db.sql](migration/db.sql) and execute it against your MySQL server instance. + + + +```sh +username: admin +password: admin +``` + +```sh +username: iris +password: iris_password +``` + +The example does not contain code to add a user to the database, as this is out of the scope of this middleware. More features can be implemented by end-developers. diff --git a/_examples/auth/basicauth/database/docker-compose.yml b/_examples/auth/basicauth/database/docker-compose.yml new file mode 100644 index 00000000..abbb4fff --- /dev/null +++ b/_examples/auth/basicauth/database/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.1' + +services: + db: + image: mysql + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_ROOT_PASSWORD: dbpassword + MYSQL_DATABASE: myapp + MYSQL_USER: user_myapp + MYSQL_PASSWORD: dbpassword + tty: true + volumes: + - ./migration:/docker-entrypoint-initdb.d + app: + build: . + ports: + - 8080:8080 + environment: + PORT: 8080 + MYSQL_USER: user_myapp + MYSQL_PASSWORD: dbpassword + MYSQL_DATABASE: myapp + MYSQL_HOST: db + restart: on-failure + healthcheck: + test: ["CMD", "curl", "-f", "tcp://db:3306"] + interval: 30s + timeout: 10s + retries: 10 + depends_on: + - db \ No newline at end of file diff --git a/_examples/auth/basicauth/database/go.mod b/_examples/auth/basicauth/database/go.mod new file mode 100644 index 00000000..ebf1523c --- /dev/null +++ b/_examples/auth/basicauth/database/go.mod @@ -0,0 +1,8 @@ +module myapp + +go 1.15 + +require ( + github.com/go-sql-driver/mysql v1.5.0 + github.com/kataras/iris/v12 master +) diff --git a/_examples/auth/basicauth/database/main.go b/_examples/auth/basicauth/database/main.go new file mode 100644 index 00000000..5e1b218b --- /dev/null +++ b/_examples/auth/basicauth/database/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "os" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/basicauth" + + _ "github.com/go-sql-driver/mysql" // lint: mysql driver. +) + +// User is just an example structure of a user, +// it MUST contain a Username and Password exported fields +// or/and complete the basicauth.User interface. +type User struct { + ID int64 `db:"id" json:"id"` + Username string `db:"username" json:"username"` + Password string `db:"password" json:"password"` + Email string `db:"email" json:"email"` +} + +// GetUsername returns the Username field. +func (u User) GetUsername() string { + return u.Username +} + +// GetPassword returns the Password field. +func (u User) GetPassword() string { + return u.Password +} + +func main() { + dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci", + getenv("MYSQL_USER", "user_myapp"), + getenv("MYSQL_PASSWORD", "dbpassword"), + getenv("MYSQL_HOST", "localhost"), + getenv("MYSQL_DATABASE", "myapp"), + ) + db, err := connect(dsn) + if err != nil { + panic(err) + } + + // Validate a user from database. + allowFunc := func(ctx iris.Context, username, password string) (interface{}, bool) { + user, err := db.getUserByUsernameAndPassword(context.Background(), username, password) + return user, err == nil + } + + opts := basicauth.Options{ + Realm: basicauth.DefaultRealm, + ErrorHandler: basicauth.DefaultErrorHandler, + Allow: allowFunc, + } + + auth := basicauth.New(opts) + + app := iris.New() + app.Use(auth) + app.Get("/", index) + app.Listen(":8080") +} + +func index(ctx iris.Context) { + user := ctx.User() + ctx.JSON(user) +} + +func getenv(key string, def string) string { + v := os.Getenv(key) + if v == "" { + return def + } + + return v +} + +type database struct { + *sql.DB +} + +func connect(dsn string) (*database, error) { + conn, err := sql.Open("mysql", dsn) + if err != nil { + return nil, err + } + err = conn.Ping() + if err != nil { + conn.Close() + return nil, err + } + + return &database{conn}, nil +} + +func (db *database) getUserByUsernameAndPassword(ctx context.Context, username, password string) (User, error) { + query := fmt.Sprintf("SELECT * FROM %s WHERE %s = ? AND %s = ? LIMIT 1", "users", "username", "password") + rows, err := db.QueryContext(ctx, query, username, password) + if err != nil { + return User{}, err + } + defer rows.Close() + if !rows.Next() { + return User{}, sql.ErrNoRows + } + + var user User + err = rows.Scan(&user.ID, &user.Username, &user.Password, &user.Email) + return user, err +} diff --git a/_examples/auth/basicauth/database/migration/db.sql b/_examples/auth/basicauth/database/migration/db.sql new file mode 100644 index 00000000..d0100157 --- /dev/null +++ b/_examples/auth/basicauth/database/migration/db.sql @@ -0,0 +1,22 @@ +CREATE DATABASE IF NOT EXISTS myapp DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE myapp; + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS users; +CREATE TABLE users ( + id int(11) NOT NULL AUTO_INCREMENT, + username varchar(255) NOT NULL, + password varchar(255) NOT NULL, + email varchar(255) NOT NULL, + PRIMARY KEY (id) +); + +INSERT INTO users (username,password,email) +VALUES + ('admin', 'admin', 'kataras2006@hotmail.com'), + ("iris", 'iris_password', 'iris-go@outlook.com'); + +SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/_examples/auth/basicauth/users_file_bcrypt/main.go b/_examples/auth/basicauth/users_file_bcrypt/main.go new file mode 100644 index 00000000..5f272185 --- /dev/null +++ b/_examples/auth/basicauth/users_file_bcrypt/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/basicauth" +) + +func main() { + auth := basicauth.Load("users.yml", basicauth.BCRYPT) + /* Same as: + opts := basicauth.Options{ + Realm: basicauth.DefaultRealm, + Allow: basicauth.AllowUsersFile("users.yml", basicauth.BCRYPT), + } + + auth := basicauth.New(opts) + */ + + app := iris.New() + app.Use(auth) + app.Get("/", index) + // kataras:kataras_pass + // makis:makis_pass + app.Listen(":8080") +} + +func index(ctx iris.Context) { + user := ctx.User() + ctx.JSON(user) +} diff --git a/_examples/auth/basicauth/users_file_bcrypt/users.yml b/_examples/auth/basicauth/users_file_bcrypt/users.yml new file mode 100644 index 00000000..46f68fa6 --- /dev/null +++ b/_examples/auth/basicauth/users_file_bcrypt/users.yml @@ -0,0 +1,12 @@ +# The file cannot be modified during the serve time. +# To support real-time users changes please use the Options.Allow instead, +# (see the database example for that). +# +# Again, the username and password (or capitalized) fields are required, +# the rest are optional, depending on your application needs. +- username: kataras + password: $2a$10$Irg8k8HWkDlvL0YDBKLCYee6j6zzIFTplJcvZYKA.B8/clHPZn2Ey # encrypted of kataras_pass + age: 27 + role: admin +- username: makis + password: $2a$10$3GXzp3J5GhHThGisbpvpZuftbmzPivDMo94XPnkTnDe7254x7sJ3O # encrypted of makis_pass diff --git a/_examples/auth/basicauth/users_list/main.go b/_examples/auth/basicauth/users_list/main.go new file mode 100644 index 00000000..619d809e --- /dev/null +++ b/_examples/auth/basicauth/users_list/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "time" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/basicauth" +) + +// User is just an example structure of a user, +// it MUST contain a Username and Password exported fields +// or complete the basicauth.User interface. +type User struct { + Username string `json:"username"` + Password string `json:"password"` + Roles []string `json:"roles"` +} + +var users = []User{ + {"admin", "admin", []string{"admin"}}, + {"kataras", "kataras_pass", []string{"manager", "author"}}, + {"george", "george_pass", []string{"member"}}, + {"john", "john_pass", []string{}}, +} + +func main() { + opts := basicauth.Options{ + Realm: basicauth.DefaultRealm, + // Defaults to 0, no expiration. + // Prompt for new credentials on a client's request + // made after 10 minutes the user has logged in: + MaxAge: 10 * time.Minute, + // Clear any expired users from the memory every one hour, + // note that the user's expiration time will be + // reseted on the next valid request (when Allow passed). + GC: basicauth.GC{ + Every: 2 * time.Hour, + }, + // The users can be a slice of custom users structure + // or a map[string]string (username:password) + // or []map[string]interface{} with username and passwords required fields, + // read the godocs for more. + Allow: basicauth.AllowUsers(users), + } + + auth := basicauth.New(opts) + // OR: basicauth.Default(users) + + app := iris.New() + app.Use(auth) + app.Get("/", index) + app.Listen(":8080") +} + +func index(ctx iris.Context) { + user, _ := ctx.User().GetRaw() + ctx.JSON(user) +} diff --git a/_examples/database/mysql/README.md b/_examples/database/mysql/README.md index 25755226..fce6b39c 100644 --- a/_examples/database/mysql/README.md +++ b/_examples/database/mysql/README.md @@ -89,7 +89,7 @@ MYSQL_HOST=localhost MYSQL_DATABASE=myapp ``` -Download the schema from [migration/myapp.sql](migration/myapp.sql) and execute it against your MySQL server instance. +Download the schema from [migration/db.sql](migration/db.sql) and execute it against your MySQL server instance. ```sql CREATE DATABASE IF NOT EXISTS myapp DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -139,7 +139,7 @@ Testing is important. The code is written in a way that testing should be trivia ## Packages -- https://github.com/dgrijalva/jwt-go (JWT parsing) +- https://github.com/kataras/jwt (JWT parsing) - https://github.com/go-sql-driver/mysql (Go Driver for MySQL) - https://github.com/DATA-DOG/go-sqlmock (Testing DB see [service/category_service_test.go](service/category_service_test.go)) - https://github.com/kataras/iris (HTTP) diff --git a/_examples/database/mysql/api/api.go b/_examples/database/mysql/api/api.go index 2691fdf4..23f16696 100644 --- a/_examples/database/mysql/api/api.go +++ b/_examples/database/mysql/api/api.go @@ -15,18 +15,19 @@ import ( // Router accepts any required dependencies and returns the main server's handler. func Router(db sql.Database, secret string) func(iris.Party) { return func(r iris.Party) { - j := jwt.HMAC(15*time.Minute, secret) - r.Use(requestid.New()) - r.Use(verifyToken(j)) + + signer := jwt.NewSigner(jwt.HS256, secret, 15*time.Minute) + r.Get("/token", writeToken(signer)) + + verify := jwt.NewVerifier(jwt.HS256, secret).Verify(nil) + r.Use(verify) // Generate a token for testing by navigating to // http://localhost:8080/token endpoint. // Copy-paste it to a ?token=$token url parameter or // open postman and put an Authentication: Bearer $token to get // access on create, update and delete endpoinds. - r.Get("/token", writeToken(j)) - var ( categoryService = service.NewCategoryService(db) productService = service.NewProductService(db) @@ -73,25 +74,19 @@ func Router(db sql.Database, secret string) func(iris.Party) { } } -func writeToken(j *jwt.JWT) iris.Handler { +func writeToken(signer *jwt.Signer) iris.Handler { return func(ctx iris.Context) { claims := jwt.Claims{ Issuer: "https://iris-go.com", - Audience: jwt.Audience{requestid.Get(ctx)}, + Audience: []string{requestid.Get(ctx)}, } - j.WriteToken(ctx, claims) - } -} - -func verifyToken(j *jwt.JWT) iris.Handler { - return func(ctx iris.Context) { - // Allow all GET. - if ctx.Method() == iris.MethodGet { - ctx.Next() + token, err := signer.Sign(claims) + if err != nil { + ctx.StopWithStatus(iris.StatusInternalServerError) return } - j.Verify(ctx) + ctx.Write(token) } } diff --git a/_examples/database/mysql/go.mod b/_examples/database/mysql/go.mod index 8129ff16..123be2ac 100644 --- a/_examples/database/mysql/go.mod +++ b/_examples/database/mysql/go.mod @@ -4,6 +4,6 @@ go 1.15 require ( github.com/go-sql-driver/mysql v1.5.0 - github.com/kataras/iris/v12 master + github.com/kataras/iris/v12 v12.2.0-alpha.0.20201117050536-962ffd67721a github.com/mailgun/groupcache/v2 v2.1.0 ) diff --git a/_examples/database/mysql/main.go b/_examples/database/mysql/main.go index f7169c5d..19088767 100644 --- a/_examples/database/mysql/main.go +++ b/_examples/database/mysql/main.go @@ -11,6 +11,8 @@ import ( "github.com/kataras/iris/v12" ) +// $ go build . + func main() { dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci", getenv("MYSQL_USER", "user_myapp"), diff --git a/context/context.go b/context/context.go index 4446f722..2d8e617c 100644 --- a/context/context.go +++ b/context/context.go @@ -1343,6 +1343,7 @@ func (ctx *Context) GetContentType() string { // trim-ed(without the charset and priority values) // header value of "Content-Type". func (ctx *Context) GetContentTypeRequested() string { + // could use mime.ParseMediaType too. return TrimHeaderValue(ctx.GetHeader(ContentTypeHeaderKey)) } diff --git a/context/context_user.go b/context/context_user.go index 009b7e63..794b78a9 100644 --- a/context/context_user.go +++ b/context/context_user.go @@ -85,7 +85,7 @@ type SimpleUser struct { AuthorizedAt time.Time `json:"authorized_at,omitempty"` ID string `json:"id,omitempty"` Username string `json:"username,omitempty"` - Password string `json:"-"` + Password string `json:"password,omitempty"` Email string `json:"email,omitempty"` Roles []string `json:"roles,omitempty"` Token json.RawMessage `json:"token,omitempty"` @@ -339,16 +339,16 @@ type ( // may or may not complete the whole User interface. // See Context.SetUser. UserPartial struct { - Raw interface{} - userGetAuthorization - userGetAuthorizedAt - userGetID - UserGetUsername - UserGetPassword - userGetEmail - userGetRoles - userGetToken - userGetField + Raw interface{} `json:"raw"` + userGetAuthorization `json:",omitempty"` + userGetAuthorizedAt `json:",omitempty"` + userGetID `json:",omitempty"` + UserGetUsername `json:",omitempty"` + UserGetPassword `json:",omitempty"` + userGetEmail `json:",omitempty"` + userGetRoles `json:",omitempty"` + userGetToken `json:",omitempty"` + userGetField `json:",omitempty"` } ) diff --git a/middleware/basicauth/basicauth.go b/middleware/basicauth/basicauth.go index 4aae6d14..e9070b0c 100644 --- a/middleware/basicauth/basicauth.go +++ b/middleware/basicauth/basicauth.go @@ -2,12 +2,15 @@ package basicauth import ( stdContext "context" + "log" + "net/http" + "net/url" "strconv" + "strings" "sync" "time" "github.com/kataras/iris/v12/context" - "github.com/kataras/iris/v12/sessions" ) func init() { @@ -15,10 +18,20 @@ func init() { } const ( - DefaultRealm = "Authorization Required" + // DefaultRealm is the default realm directive value on Default and Load functions. + DefaultRealm = "Authorization Required" + // DefaultMaxTriesCookie is the default cookie name to store the + // current amount of login failures when MaxTries > 0. DefaultMaxTriesCookie = "basicmaxtries" + // DefaultCookieMaxAge is the default cookie max age on MaxTries, + // when the Options.MaxAge is zero. + DefaultCookieMaxAge = time.Hour ) +// cookieExpireDelete may be set on Cookie.Expire for expiring the given cookie. +// Note that the MaxAge is set but we set Expires field in order to support very old browsers too. +var cookieExpireDelete = time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) + const ( authorizationType = "Basic Authentication" authenticateHeaderKey = "WWW-Authenticate" @@ -27,10 +40,26 @@ const ( proxyAuthorizationHeaderKey = "Proxy-Authorization" ) +// AuthFunc accepts the current request and the username and password user inputs +// and it should optionally return a user value and report whether the login succeed or not. +// Look the Options.Allow field. +// +// Default implementations are: +// AllowUsers and AllowUsersFile functions. type AuthFunc func(ctx *context.Context, username, password string) (interface{}, bool) +// ErrorHandler should handle the given request credentials failure. +// See Options.ErrorHandler and DefaultErrorHandler for details. +type ErrorHandler func(ctx *context.Context, err error) + +// Options holds the necessary information that the BasicAuth instance needs to perform. +// The only required value is the Allow field. +// +// Usage: +// opts := Options { ... } +// auth := New(opts) type Options struct { - // Realm http://tools.ietf.org/html/rfc2617#section-1.2. + // Realm directive, read http://tools.ietf.org/html/rfc2617#section-1.2 for details. // E.g. "Authorization Required". Realm string // In the case of proxies, the challenging status code is 407 (Proxy Authentication Required), @@ -40,44 +69,59 @@ type Options struct { // 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 + // If set to true then any non-https request will immediately + // dropped with a 505 status code (StatusHTTPVersionNotSupported) response. + // + // Defaults to false. + HTTPSOnly bool + // Allow is the only one required field for the Options type. + // Can be customized to validate a username and password combination + // and return a user object, e.g. fetch from database. + // + // There are two available builtin values, the AllowUsers and AllowUsersFile, + // both of them decode a static list of users and compares with the user input (see BCRYPT function too). // Usage: // - Allow: AllowUsers(iris.Map{"username": "...", "password": "...", "other_field": ...}, [BCRYPT]) // - Allow: AllowUsersFile("users.yml", [BCRYPT]) + // Look the user.go source file for details. 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: 30 * time.Minute MaxAge time.Duration + // If greater than zero then the server will send 403 forbidden status code afer + // MaxTries amount of sign in failures (see MaxTriesCookie). + // Note that the client can modify the cookie and its value, + // do NOT depend for any type of custom domain logic based on this field. + // By default the server will re-ask for credentials on invalid credentials, each time. + MaxTries int + // MaxTriesCookie is the cookie name the middleware uses to + // store the failures amount on the client side. + // The lifetime of the cookie is the same as the configured MaxAge or one hour, + // therefore a forbidden client can request for authentication again after expiration. + // + // You can always set custom logic on the Allow field as you have access to the current request instance. + // + // Defaults to "basicmaxtries". + // The MaxTries should be set to greater than zero. + MaxTriesCookie string + // ErrorHandler handles the given request credentials failure. + // E.g when the client tried to access a protected resource + // with empty or invalid or expired credentials or + // when Allow returned false and MaxTries consumed. + // + // Defaults to the DefaultErrorHandler, do not modify if you don't need to. + ErrorHandler ErrorHandler + // ErrorLogger if not nil then it logs any credentials failure errors + // that are going to be sent to the client. Set it on debug development state. + // Usage: + // ErrorLogger = log.New(os.Stderr, "", log.LstdFlags) + // + // Defaults to nil. + ErrorLogger *log.Logger // 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, @@ -85,22 +129,34 @@ type Options struct { // The standard context can be used for the internal ticker cancelation, it can be nil. // // Usage: - // GC: basicauth.GC{Every: 2*time.Hour} + // GC: basicauth.GC{Every: 2 * time.Hour} GC GC } +// GC holds the context and the tick duration to clear expired stored credentials. +// See the Options.GC field. 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 +// BasicAuth implements the basic access authentication. +// It is a method for an HTTP client (e.g. a web browser) +// to provide a user name and password when making a request. +// Basic authentication implementation is the simplest technique +// for enforcing access controls to web resources because it does not require +// cookies, session identifiers, or login pages; rather, +// HTTP Basic authentication uses standard fields in the HTTP header. // -// 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. +// As the username and password are passed over the network as clear text +// the basic authentication scheme is not secure on plain HTTP communication. +// It is base64 encoded, but base64 is a reversible encoding. +// 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. +// +// Read https://tools.ietf.org/html/rfc2617 and +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication for details. type BasicAuth struct { opts Options // built based on proxy field @@ -110,13 +166,35 @@ type BasicAuth struct { // built based on realm field. authenticateHeaderValue string - credentials map[string]*time.Time // key = username:password, value = expiration time (if MaxAge > 0). - mu sync.RWMutex // protects the credentials as they can modified. + // credentials stores the user expiration, + // key = username:password, value = expiration time (if MaxAge > 0). + credentials map[string]*time.Time // TODO: think of just a uint64 here (unix seconds). + // protects the credentials concurrent access. + mu sync.RWMutex } +// New returns a new basic authentication middleware. +// The result should be used to wrap an existing handler or the HTTP application's root router. +// +// Example Code: +// opts := basicauth.Options{ +// Realm: basicauth.DefaultRealm, +// ErrorHandler: basicauth.DefaultErrorHandler, +// MaxAge: 2 * time.Hour, +// GC: basicauth.GC{ +// Every: 3 * time.Hour, +// }, +// Allow: basicauth.AllowUsers(users), +// } +// auth := basicauth.New(opts) +// app.Use(auth) +// +// Access the user in the route handler with: ctx.User().GetRaw().(*myCustomType). +// +// Look the BasicAuth type docs for more information. func New(opts Options) context.Handler { var ( - askCode = 401 + askCode = http.StatusUnauthorized authorizationHeader = authorizationHeaderKey authenticateHeader = authenticateHeaderKey authenticateHeaderValue = "Basic" @@ -131,7 +209,7 @@ func New(opts Options) context.Handler { } if opts.Proxy { - askCode = 407 + askCode = http.StatusProxyAuthRequired authenticateHeader = proxyAuthenticateHeaderKey authorizationHeader = proxyAuthorizationHeaderKey } @@ -140,6 +218,10 @@ func New(opts Options) context.Handler { opts.MaxTriesCookie = DefaultMaxTriesCookie } + if opts.ErrorHandler == nil { + opts.ErrorHandler = DefaultErrorHandler + } + b := &BasicAuth{ opts: opts, askCode: askCode, @@ -156,10 +238,22 @@ func New(opts Options) context.Handler { 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. +// Default returns a new basic authentication middleware +// based on pre-defined user list. +// A user can hold any custom fields but the username and password +// are required as they are compared against the user input +// when access to protected resource is requested. +// A user list can defined with one of the following values: +// map[string]string form of: {username:password, ...} +// map[string]interface{} form of: {"username": {"password": "...", "other_field": ...}, ...} +// []T which T completes the User interface, where T is a struct value +// []T which T contains at least Username and Password fields. +// +// Usage: +// auth := Default(map[string]string{ +// "admin": "admin", +// "john": "p@ss", +// }) func Default(users interface{}, userOpts ...UserAuthOption) context.Handler { opts := Options{ Realm: DefaultRealm, @@ -168,6 +262,11 @@ func Default(users interface{}, userOpts ...UserAuthOption) context.Handler { return New(opts) } +// Load same as Default but instead of a hard-coded user list it accepts +// a filename to load the users from. +// +// Usage: +// auth := Load("users.yml") func Load(jsonOrYamlFilename string, userOpts ...UserAuthOption) context.Handler { opts := Options{ Realm: DefaultRealm, @@ -176,71 +275,68 @@ func Load(jsonOrYamlFilename string, userOpts ...UserAuthOption) context.Handler 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) - } + cookie := ctx.GetCookie(b.opts.MaxTriesCookie) + if cookie != "" { + tries, _ = strconv.Atoi(cookie) } return } 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)) + maxAge := b.opts.MaxAge + if maxAge == 0 { + maxAge = DefaultCookieMaxAge // 1 hour. } + + c := &http.Cookie{ + Name: b.opts.MaxTriesCookie, + Path: "/", + Value: url.QueryEscape(strconv.Itoa(tries)), + HttpOnly: true, + Expires: time.Now().Add(maxAge), + MaxAge: int(maxAge.Seconds()), + } + + ctx.SetCookie(c) } 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) + ctx.RemoveCookie(b.opts.MaxTriesCookie) +} + +func isHTTPS(r *http.Request) bool { + return (strings.EqualFold(r.URL.Scheme, "https") || r.TLS != nil) && r.ProtoMajor == 2 +} + +func (b *BasicAuth) handleError(ctx *context.Context, err error) { + if b.opts.ErrorLogger != nil { + b.opts.ErrorLogger.Println(err) } + + // should not be nil as it's defaulted on New. + b.opts.ErrorHandler(ctx, err) } // 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) { + if b.opts.HTTPSOnly && !isHTTPS(ctx.Request()) { + b.handleError(ctx, ErrHTTPVersion{}) + return + } + header := ctx.GetHeader(b.authorizationHeader) fullUser, username, password, ok := decodeHeader(header) - if !ok { // Header is malformed or missing. - b.askForCredentials(ctx) + if !ok { // Header is malformed or missing (e.g. browser cancel button on user prompt). + b.handleError(ctx, ErrCredentialsMissing{ + Header: header, + AuthenticateHeader: b.authenticateHeader, + AuthenticateHeaderValue: b.authenticateHeaderValue, + Code: b.askCode, + }) return } @@ -259,12 +355,24 @@ func (b *BasicAuth) serveHTTP(ctx *context.Context) { 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. + b.handleError(ctx, ErrCredentialsForbidden{ + Username: username, + Password: password, + Tries: tries, + Age: b.opts.MaxAge, + }) return } } - b.askForCredentials(ctx) + b.handleError(ctx, ErrCredentialsInvalid{ + Username: username, + Password: password, + CurrentTries: tries, + AuthenticateHeader: b.authenticateHeader, + AuthenticateHeaderValue: b.authenticateHeaderValue, + Code: b.askCode, + }) return } @@ -283,8 +391,15 @@ func (b *BasicAuth) serveHTTP(ctx *context.Context) { b.mu.Lock() // Delete the entry. delete(b.credentials, fullUser) b.mu.Unlock() + // Re-ask for new credentials. - b.askForCredentials(ctx) + b.handleError(ctx, ErrCredentialsExpired{ + Username: username, + Password: password, + AuthenticateHeader: b.authenticateHeader, + AuthenticateHeaderValue: b.authenticateHeaderValue, + Code: b.askCode, + }) return } @@ -314,6 +429,10 @@ func (b *BasicAuth) serveHTTP(ctx *context.Context) { } } + // Store user instance and logout function. + // Note that the end-developer has always have access + // to the Request.BasicAuth, however, we support any user struct, + // so we must store it on this request instance so it can be retrieved later on. ctx.SetUser(user) ctx.SetLogoutFunc(b.logout) @@ -336,12 +455,15 @@ func (b *BasicAuth) logout(ctx *context.Context) { if !ok { // If the custom user does - // not implement those two, then extract from the request header: + // not implement the User interface, then extract from the request header (most common scenario): header := ctx.GetHeader(b.authorizationHeader) fullUser, username, password, ok = decodeHeader(header) } if ok { // If it's authorized then try to lock and delete. + ctx.SetUser(nil) + ctx.SetLogoutFunc(nil) + if b.opts.Proxy { ctx.Request().Header.Del(proxyAuthorizationHeaderKey) } @@ -374,16 +496,16 @@ func (b *BasicAuth) runGC(ctx stdContext.Context, every time.Duration) { } } -// gc removes all entries expired based on the max age or all entries (if max age is missing). +// gc removes all entries expired based on the max age or all entries (if max age is missing), +// note that this does not mean that the server will send 401/407 to the next request, +// when the request header credentials are still valid (Allow passed). 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) { + if expiresAt == nil || expiresAt.Before(now) { markedForDeletion = append(markedForDeletion, fullUser) } } diff --git a/middleware/basicauth/basicauth_test.go b/middleware/basicauth/basicauth_test.go index f04478ad..e19147f2 100644 --- a/middleware/basicauth/basicauth_test.go +++ b/middleware/basicauth/basicauth_test.go @@ -8,7 +8,7 @@ import ( "github.com/kataras/iris/v12" "github.com/kataras/iris/v12/httptest" - basicauth "github.com/kataras/iris/v12/middleware/basicauth" + "github.com/kataras/iris/v12/middleware/basicauth" ) func TestBasicAuthUseRouter(t *testing.T) { diff --git a/middleware/basicauth/error.go b/middleware/basicauth/error.go new file mode 100644 index 00000000..af991515 --- /dev/null +++ b/middleware/basicauth/error.go @@ -0,0 +1,108 @@ +package basicauth + +import ( + "fmt" + "net/http" + "time" + + "github.com/kataras/iris/v12/context" +) + +type ( + // ErrHTTPVersion is fired when Options.HTTPSOnly was enabled + // and the current request is a plain http one. + ErrHTTPVersion struct{} + + // ErrCredentialsForbidden is fired when Options.MaxTries have been consumed + // by the user and the client is forbidden to retry at least for "Age" time. + ErrCredentialsForbidden struct { + Username string + Password string + Tries int + Age time.Duration + } + + // ErrCredentialsMissing is fired when the authorization header is empty or malformed. + ErrCredentialsMissing struct { + Header string + + AuthenticateHeader string + AuthenticateHeaderValue string + Code int + } + + // ErrCredentialsInvalid is fired when the user input does not match with an existing user. + ErrCredentialsInvalid struct { + Username string + Password string + CurrentTries int + + AuthenticateHeader string + AuthenticateHeaderValue string + Code int + } + + // ErrCredentialsExpired is fired when the username:password combination is valid + // but the memory stored user has been expired. + ErrCredentialsExpired struct { + Username string + Password string + + AuthenticateHeader string + AuthenticateHeaderValue string + Code int + } +) + +func (e ErrHTTPVersion) Error() string { + return "http version not supported" +} + +func (e ErrCredentialsForbidden) Error() string { + return fmt.Sprintf("credentials: forbidden <%s:%s> for <%s> after <%d> attempts", e.Username, e.Password, e.Age, e.Tries) +} + +func (e ErrCredentialsMissing) Error() string { + if e.Header != "" { + return fmt.Sprintf("credentials: malformed <%s>", e.Header) + } + + return "empty credentials" +} + +func (e ErrCredentialsInvalid) Error() string { + return fmt.Sprintf("credentials: invalid <%s:%s> current tries <%d>", e.Username, e.Password, e.CurrentTries) +} + +func (e ErrCredentialsExpired) Error() string { + return fmt.Sprintf("credentials: expired <%s:%s>", e.Username, e.Password) +} + +// DefaultErrorHandler is the default error handler for the Options.ErrorHandler field. +func DefaultErrorHandler(ctx *context.Context, err error) { + switch e := err.(type) { + case ErrHTTPVersion: + ctx.StopWithStatus(http.StatusHTTPVersionNotSupported) + case ErrCredentialsForbidden: + // 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. + ctx.StopWithStatus(http.StatusForbidden) + case ErrCredentialsMissing: + unauthorize(ctx, e.AuthenticateHeader, e.AuthenticateHeaderValue, e.Code) + case ErrCredentialsInvalid: + unauthorize(ctx, e.AuthenticateHeader, e.AuthenticateHeaderValue, e.Code) + case ErrCredentialsExpired: + unauthorize(ctx, e.AuthenticateHeader, e.AuthenticateHeaderValue, e.Code) + default: + // This will never happen. + ctx.StopWithText(http.StatusInternalServerError, "unknown error: %v", err) + } +} + +// unauthorize sends a 401 status code (or 407 if Proxy was set to true) +// which client should catch and prompt for username:password credentials. +func unauthorize(ctx *context.Context, authHeader, authHeaderValue string, code int) { + ctx.Header(authHeader, authHeaderValue) + ctx.StopWithStatus(code) +} diff --git a/middleware/basicauth/user_auth.go b/middleware/basicauth/user.go similarity index 71% rename from middleware/basicauth/user_auth.go rename to middleware/basicauth/user.go index 808981a3..4f66c1dc 100644 --- a/middleware/basicauth/user_auth.go +++ b/middleware/basicauth/user.go @@ -8,17 +8,50 @@ import ( "strings" "github.com/kataras/iris/v12/context" + "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" ) +// ReadFile can be used to customize the way the +// AllowUsersFile function is loading the filename from. +// Example of usage: embedded users.yml file. +// Defaults to the `ioutil.ReadFile` which reads the file from the physical disk. +var ReadFile = ioutil.ReadFile + +// User is a partial part of the iris.User interface. +// It's used to declare a static slice of registered User for authentication. +type User interface { + context.UserGetUsername + context.UserGetPassword +} + +// UserAuthOptions holds optional user authentication options +// that can be given to the builtin Default and Load (and AllowUsers, AllowUsersFile) functions. type UserAuthOptions struct { - // Defaults to plain check, can be modified for encrypted passwords, see `BCRYPT`. + // Defaults to plain check, can be modified for encrypted passwords, + // see the BCRYPT optional function. ComparePassword func(stored, userPassword string) bool } +// UserAuthOption is the option function type +// for the Default and Load (and AllowUsers, AllowUsersFile) functions. +// +// See BCRYPT for an implementation. type UserAuthOption func(*UserAuthOptions) +// BCRYPT it is a UserAuthOption, it compares a bcrypt hashed password with its user input. +// Reports true on success and false on failure. +// +// Useful when the users passwords are encrypted +// using the Provos and Mazières's bcrypt adaptive hashing algorithm. +// See https://www.usenix.org/legacy/event/usenix99/provos/provos.pdf. +// +// Usage: +// Default(..., BCRYPT) OR +// Load(..., BCRYPT) OR +// Options.Allow = AllowUsers(..., BCRYPT) OR +// OPtions.Allow = AllowUsersFile(..., BCRYPT) func BCRYPT(opts *UserAuthOptions) { opts.ComparePassword = func(stored, userPassword string) bool { err := bcrypt.CompareHashAndPassword([]byte(stored), []byte(userPassword)) @@ -40,16 +73,15 @@ func toUserAuthOptions(opts []UserAuthOption) (options UserAuthOptions) { return options } -type User interface { - context.UserGetUsername - context.UserGetPassword -} - -// Users -// - 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. +// AllowUsers is an AuthFunc which authenticates user input based on a (static) user list. +// The "users" input parameter can be one of the following forms: +// map[string]string e.g. {username: password, username: password...}. +// []map[string]interface{} e.g. []{"username": "...", "password": "...", "other_field": ...}, ...}. +// []T which T completes the User interface. +// []T which T contains at least Username and Password fields. +// +// Usage: +// New(Options{Allow: AllowUsers(..., [BCRYPT])}) func AllowUsers(users interface{}, opts ...UserAuthOption) AuthFunc { // create a local user structure to be used in the map copy, // takes longer to initialize but faster to serve. @@ -119,12 +151,24 @@ func userMap(usernamePassword map[string]string, opts ...UserAuthOption) AuthFun } } +// AllowUsersFile is an AuthFunc which authenticates user input based on a (static) user list +// loaded from a file on initialization. +// +// Example Code: +// New(Options{Allow: AllowUsersFile("users.yml", BCRYPT)}) +// The users.yml file looks like the following: +// - username: kataras +// password: kataras_pass +// age: 27 +// role: admin +// - username: makis +// password: makis_password +// ... func AllowUsersFile(jsonOrYamlFilename string, opts ...UserAuthOption) AuthFunc { var ( usernamePassword map[string]string // no need to support too much forms, this would be for: // "$username": { "password": "$pass", "other_field": ...} - // users map[string]map[string]interface{} userList []map[string]interface{} ) @@ -152,7 +196,7 @@ func AllowUsersFile(jsonOrYamlFilename string, opts ...UserAuthOption) AuthFunc } func decodeFile(src string, dest ...interface{}) error { - data, err := ioutil.ReadFile(src) + data, err := ReadFile(src) if err != nil { return err } diff --git a/middleware/basicauth/user_auth_test.go b/middleware/basicauth/user_test.go similarity index 97% rename from middleware/basicauth/user_auth_test.go rename to middleware/basicauth/user_test.go index 1fa96fc5..3ce80a12 100644 --- a/middleware/basicauth/user_auth_test.go +++ b/middleware/basicauth/user_test.go @@ -20,7 +20,7 @@ type IUserRepository interface { // Test a custom implementation of AuthFunc with a user repository. // This is a usage example of custom AuthFunc implementation. func UserRepository(repo IUserRepository, newUserPtr func() interface{}) AuthFunc { - return func(ctx *context.Context, username, password string) (interface{}, bool) { + return func(_ *context.Context, username, password string) (interface{}, bool) { dest := newUserPtr() err := repo.GetByUsernameAndPassword(dest, username, password) if err == nil { @@ -37,7 +37,7 @@ type testUser struct { email string // custom field. } -// GetUsername & Getpassword complete the User interface (optional but useful on Context.User()). +// GetUsername & Getpassword complete the User interface. func (u *testUser) GetUsername() string { return u.username } @@ -224,6 +224,7 @@ func TestAllowUsersFile(t *testing.T) { if tt.ok { // store the hashed password. tt.password = mustGeneratePassword(t, tt.inputPassword) + // store and write the username and hashed password. tt.user["username"] = tt.username tt.user["password"] = tt.password