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 57% rename from _examples/auth/basicauth/main.go rename to _examples/auth/basicauth/basic/main.go index 39132d6e..36465501 100644 --- a/_examples/auth/basicauth/main.go +++ b/_examples/auth/basicauth/basic/main.go @@ -1,8 +1,6 @@ package main import ( - "time" - "github.com/kataras/iris/v12" "github.com/kataras/iris/v12/middleware/basicauth" ) @@ -10,33 +8,48 @@ import ( func newApp() *iris.Application { app := iris.New() - authConfig := basicauth.Config{ - Users: map[string]string{"myusername": "mypassword", "mySecondusername": "mySecondpassword"}, - Realm: "Authorization Required", // defaults to "Authorization Required" - Expires: time.Duration(30) * time.Minute, - } + /* + opts := basicauth.Options{ + Realm: "Authorization Required", + MaxAge: 30 * time.Minute, + GC: basicauth.GC{ + Every: 2 * time.Hour, + }, + Allow: basicauth.AllowUsers(map[string]string{ + "myusername": "mypassword", + "mySecondusername": "mySecondpassword", + }), + MaxTries: 2, + } + auth := basicauth.New(opts) - authentication := basicauth.New(authConfig) + OR simply: + */ - // to global app.Use(authentication) (or app.UseGlobal before the .Run) + auth := basicauth.Default(map[string]string{ + "myusername": "mypassword", + "mySecondusername": "mySecondpassword", + }) + + // to global app.Use(auth) (or app.UseGlobal before the .Run) // to routes /* - app.Get("/mysecret", authentication, h) + app.Get("/mysecret", auth, h) */ app.Get("/", func(ctx iris.Context) { ctx.Redirect("/admin") }) // to party - needAuth := app.Party("/admin", authentication) + 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) } @@ -50,12 +63,13 @@ 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. // OR: user := ctx.User() + // OR ctx.User().GetRaw() to get the underline value. username, _ := user.GetUsername() password, _ := user.GetPassword() ctx.Writef("%s %s:%s", ctx.Path(), username, password) diff --git a/_examples/auth/basicauth/main_test.go b/_examples/auth/basicauth/basic/main_test.go similarity index 100% rename from _examples/auth/basicauth/main_test.go rename to _examples/auth/basicauth/basic/main_test.go 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..e8e439e9 --- /dev/null +++ b/_examples/auth/basicauth/database/main.go @@ -0,0 +1,114 @@ +package main // Look README.md + +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().GetRaw() + // user is a type of main.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/auth/jwt/tutorial/go.mod b/_examples/auth/jwt/tutorial/go.mod index d35aad38..0d8f1db0 100644 --- a/_examples/auth/jwt/tutorial/go.mod +++ b/_examples/auth/jwt/tutorial/go.mod @@ -4,7 +4,7 @@ go 1.15 require ( github.com/google/uuid v1.1.2 - github.com/kataras/iris/v12 v12.2.0-alpha.0.20201106220849-7a19cfb2112f + github.com/kataras/iris/v12 v12.2.0-alpha.0.20201113181155-4d09475c290d golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 ) diff --git a/_examples/database/mysql/README.md b/_examples/database/mysql/README.md index 25755226..b11b92fd 100644 --- a/_examples/database/mysql/README.md +++ b/_examples/database/mysql/README.md @@ -5,19 +5,19 @@ | Method | Path | Description | URL Parameters | Body | Auth Required | |--------|---------------------|------------------------|--------------- |----------------------------|---------------| | ANY | /token | Prints a new JWT Token | - | - | - | -| GET | /category | Lists a set of Categories | offset, limit, order | - | - | +| GET | /category | Lists a set of Categories | offset, limit, order | - | Token | | POST | /category | Creates a Category | - | JSON [Full Category](migration/api_category/create_category.json) | Token | | PUT | /category | Fully-Updates a Category | - | JSON [Full Category](migration/api_category/update_category.json) | Token | | PATCH | /category/{id} | Partially-Updates a Category | - | JSON [Partial Category](migration/api_category/update_partial_category.json) | Token | -| GET | /category/{id} | Prints a Category | - | - | - | +| GET | /category/{id} | Prints a Category | - | - | Token | | DELETE | /category/{id} | Deletes a Category | - | - | Token | -| GET | /category/{id}/products | Lists all Products from a Category | offset, limit, order | - | - | +| GET | /category/{id}/products | Lists all Products from a Category | offset, limit, order | - | Token | | POST | /category/{id}/products | (Batch) Assigns one or more Products to a Category | - | JSON [Products](migration/api_category/insert_products_category.json) | Token | -| GET | /product | Lists a set of Products (cache) | offset, limit, order | - | - | +| GET | /product | Lists a set of Products (cache) | offset, limit, order | - | Token | | POST | /product | Creates a Product | - | JSON [Full Product](migration/api_product/create_product.json) | Token | | PUT | /product | Fully-Updates a Product | - | JSON [Full Product](migration/api_product/update_product.json) | Token | | PATCH | /product/{id} | Partially-Updates a Product | - | JSON [Partial Product](migration/api_product/update_partial_product.json) | Token | -| GET | /product/{id} | Prints a Product (cache) | - | - | - | +| GET | /product/{id} | Prints a Product (cache) | - | - | Token | | DELETE | /product/{id} | Deletes a Product | - | - | Token | @@ -71,7 +71,7 @@ Download the folder. Install [Docker](https://www.docker.com/) and execute the command below ```sh -$ docker-compose up +$ docker-compose up --build ``` ### Install (Manually) @@ -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/api/category_handler.go b/_examples/database/mysql/api/category_handler.go index 7e8c3f84..fcddbd95 100644 --- a/_examples/database/mysql/api/category_handler.go +++ b/_examples/database/mysql/api/category_handler.go @@ -168,6 +168,11 @@ func (h *CategoryHandler) Delete(ctx iris.Context) { affected, err := h.service.DeleteByID(ctx.Request().Context(), id) if err != nil { + if err == sql.ErrNoRows { + writeEntityNotFound(ctx) + return + } + debugf("CategoryHandler.Delete(DB): %v", err) writeInternalServerError(ctx) return @@ -201,7 +206,7 @@ func (h *CategoryHandler) ListProducts(ctx iris.Context) { var products entity.Products err := h.service.List(ctx.Request().Context(), &products, opts) - if err != nil { + if err != nil && err != sql.ErrNoRows { debugf("CategoryHandler.ListProducts(DB) (table=%s where=%s=%v limit=%d offset=%d): %v", opts.Table, opts.WhereColumn, opts.WhereValue, opts.Limit, opts.Offset, err) diff --git a/_examples/database/mysql/main.go b/_examples/database/mysql/main.go index f7169c5d..a135a317 100644 --- a/_examples/database/mysql/main.go +++ b/_examples/database/mysql/main.go @@ -1,4 +1,4 @@ -package main +package main // Look README.md import ( "fmt" diff --git a/_examples/database/mysql/migration/api_postman.json b/_examples/database/mysql/migration/api_postman.json index 1ef1eba0..dfdd84d1 100644 --- a/_examples/database/mysql/migration/api_postman.json +++ b/_examples/database/mysql/migration/api_postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "d3a2fdf6-9ebd-4e85-827d-385592a71fd6", + "_postman_id": "7b8b53f8-859a-425a-aa9c-28bc2a2d5ef7", "name": "myapp (api-test)", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -15,8 +15,9 @@ "header": [ { "key": "Authorization", - "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk2MzkzNjd9.cYohwgUpe-Z7ac0LPpz4Adi5QXJmtwD1ZRpXrMUMPN0", - "type": "text" + "value": "Bearer {{token}}", + "type": "text", + "disabled": true } ], "body": { @@ -47,7 +48,14 @@ "name": "Get By ID", "request": { "method": "GET", - "header": [], + "header": [ + { + "key": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lyaXMtZ28uY29tIiwiYXVkIjpbIjljOTY5ZDg5LTBkZGYtNDlkMC1hYzcxLTI3MmU3YmY5NjkwMCJdLCJpYXQiOjE2MDYyNzE1NDYsImV4cCI6MTYwNjI3MjQ0Nn0.l2_5iqfEaC68UySTQNoDx2sfzn031tHiTdm2kZoNkWQ", + "type": "text", + "disabled": true + } + ], "url": { "raw": "http://localhost:8080/category/1", "protocol": "http", @@ -71,7 +79,14 @@ }, "request": { "method": "GET", - "header": [], + "header": [ + { + "key": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lyaXMtZ28uY29tIiwiYXVkIjpbIjljOTY5ZDg5LTBkZGYtNDlkMC1hYzcxLTI3MmU3YmY5NjkwMCJdLCJpYXQiOjE2MDYyNzE1NDYsImV4cCI6MTYwNjI3MjQ0Nn0.l2_5iqfEaC68UySTQNoDx2sfzn031tHiTdm2kZoNkWQ", + "type": "text", + "disabled": true + } + ], "body": { "mode": "raw", "raw": "" @@ -113,7 +128,8 @@ { "key": "Authorization", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU", - "type": "text" + "type": "text", + "disabled": true } ], "body": { @@ -150,7 +166,8 @@ { "key": "Authorization", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU", - "type": "text" + "type": "text", + "disabled": true } ], "url": { @@ -177,7 +194,8 @@ { "key": "Authorization", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU", - "type": "text" + "type": "text", + "disabled": true } ], "body": { @@ -185,7 +203,7 @@ "raw": "{\r\n \"title\": \"computers-technology\"\r\n}" }, "url": { - "raw": "http://localhost:8080/category/1", + "raw": "http://localhost:8080/category/3", "protocol": "http", "host": [ "localhost" @@ -204,7 +222,14 @@ "name": "List Products", "request": { "method": "GET", - "header": [], + "header": [ + { + "key": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lyaXMtZ28uY29tIiwiYXVkIjpbIjljOTY5ZDg5LTBkZGYtNDlkMC1hYzcxLTI3MmU3YmY5NjkwMCJdLCJpYXQiOjE2MDYyNzE1NDYsImV4cCI6MTYwNjI3MjQ0Nn0.l2_5iqfEaC68UySTQNoDx2sfzn031tHiTdm2kZoNkWQ", + "type": "text", + "disabled": true + } + ], "url": { "raw": "http://localhost:8080/category/1/products?offset=0&limit=30&by=price&order=asc", "protocol": "http", @@ -214,7 +239,7 @@ "port": "8080", "path": [ "category", - "3", + "1", "products" ], "query": [ @@ -248,7 +273,8 @@ { "key": "Authorization", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU", - "type": "text" + "type": "text", + "disabled": true } ], "body": { @@ -256,7 +282,7 @@ "raw": "[{\r\n \"title\": \"product-1\",\r\n \"image_url\": \"https://images.product1.png\",\r\n \"price\": 42.42,\r\n \"description\": \"a description for product-1\"\r\n}, {\r\n \"title\": \"product-2\",\r\n \"image_url\": \"https://images.product2.png\",\r\n \"price\": 32.1,\r\n \"description\": \"a description for product-2\"\r\n}, {\r\n \"title\": \"product-3\",\r\n \"image_url\": \"https://images.product3.png\",\r\n \"price\": 52321321.32,\r\n \"description\": \"a description for product-3\"\r\n}, {\r\n \"title\": \"product-4\",\r\n \"image_url\": \"https://images.product4.png\",\r\n \"price\": 77.4221,\r\n \"description\": \"a description for product-4\"\r\n}, {\r\n \"title\": \"product-5\",\r\n \"image_url\": \"https://images.product5.png\",\r\n \"price\": 55.1,\r\n \"description\": \"a description for product-5\"\r\n}, {\r\n \"title\": \"product-6\",\r\n \"image_url\": \"https://images.product6.png\",\r\n \"price\": 53.32,\r\n \"description\": \"a description for product-6\"\r\n}]" }, "url": { - "raw": "http://localhost:8080/category/1/products", + "raw": "http://localhost:8080/category/3/products", "protocol": "http", "host": [ "localhost" @@ -282,7 +308,14 @@ "name": "List", "request": { "method": "GET", - "header": [], + "header": [ + { + "key": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lyaXMtZ28uY29tIiwiYXVkIjpbIjljOTY5ZDg5LTBkZGYtNDlkMC1hYzcxLTI3MmU3YmY5NjkwMCJdLCJpYXQiOjE2MDYyNzE1NDYsImV4cCI6MTYwNjI3MjQ0Nn0.l2_5iqfEaC68UySTQNoDx2sfzn031tHiTdm2kZoNkWQ", + "type": "text", + "disabled": true + } + ], "url": { "raw": "http://localhost:8080/product?offset=0&limit=30&by=price&order=asc", "protocol": "http", @@ -320,7 +353,14 @@ "name": "Get By ID", "request": { "method": "GET", - "header": [], + "header": [ + { + "key": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lyaXMtZ28uY29tIiwiYXVkIjpbIjljOTY5ZDg5LTBkZGYtNDlkMC1hYzcxLTI3MmU3YmY5NjkwMCJdLCJpYXQiOjE2MDYyNzE1NDYsImV4cCI6MTYwNjI3MjQ0Nn0.l2_5iqfEaC68UySTQNoDx2sfzn031tHiTdm2kZoNkWQ", + "type": "text", + "disabled": true + } + ], "url": { "raw": "http://localhost:8080/product/1", "protocol": "http", @@ -345,7 +385,8 @@ { "key": "Authorization", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU", - "type": "text" + "type": "text", + "disabled": true } ], "url": { @@ -372,7 +413,8 @@ { "key": "Authorization", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU", - "type": "text" + "type": "text", + "disabled": true } ], "body": { @@ -402,7 +444,8 @@ { "key": "Authorization", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU", - "type": "text" + "type": "text", + "disabled": true } ], "body": { @@ -432,7 +475,8 @@ { "key": "Authorization", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU", - "type": "text" + "type": "text", + "disabled": true } ], "body": { @@ -480,5 +524,44 @@ "response": [] } ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lyaXMtZ28uY29tIiwiYXVkIjpbIjljOTY5ZDg5LTBkZGYtNDlkMC1hYzcxLTI3MmU3YmY5NjkwMCJdLCJpYXQiOjE2MDYyNzE1NDYsImV4cCI6MTYwNjI3MjQ0Nn0.l2_5iqfEaC68UySTQNoDx2sfzn031tHiTdm2kZoNkWQ", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "id": "f27c3c2d-efdc-4922-b70c-258784a1d59b", + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "id": "44d94797-9cc6-4ecd-adc5-7ad5329d79c4", + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "id": "38156b9f-e623-4974-a315-51c931670f23", + "key": "token", + "value": "token" + } + ], "protocolProfileBehavior": {} } \ No newline at end of file diff --git a/_examples/dependency-injection/overview/web/middleware/basicauth.go b/_examples/dependency-injection/overview/web/middleware/basicauth.go index 5d61fc72..8cda6629 100644 --- a/_examples/dependency-injection/overview/web/middleware/basicauth.go +++ b/_examples/dependency-injection/overview/web/middleware/basicauth.go @@ -5,8 +5,6 @@ package middleware import "github.com/kataras/iris/v12/middleware/basicauth" // BasicAuth middleware sample. -var BasicAuth = basicauth.New(basicauth.Config{ - Users: map[string]string{ - "admin": "password", - }, +var BasicAuth = basicauth.Default(map[string]string{ + "admin": "password", }) diff --git a/_examples/file-server/file-server/main.go b/_examples/file-server/file-server/main.go index f353adc2..200aa365 100644 --- a/_examples/file-server/file-server/main.go +++ b/_examples/file-server/file-server/main.go @@ -74,10 +74,8 @@ func main() { }), }) - auth := basicauth.New(basicauth.Config{ - Users: map[string]string{ - "myusername": "mypassword", - }, + auth := basicauth.Default(map[string]string{ + "myusername": "mypassword", }) filesRouter.Delete("/{file:path}", auth, deleteFile) diff --git a/_examples/mvc/login/web/middleware/basicauth.go b/_examples/mvc/login/web/middleware/basicauth.go index eaf085ba..093bdb54 100644 --- a/_examples/mvc/login/web/middleware/basicauth.go +++ b/_examples/mvc/login/web/middleware/basicauth.go @@ -5,8 +5,6 @@ package middleware import "github.com/kataras/iris/v12/middleware/basicauth" // BasicAuth middleware sample. -var BasicAuth = basicauth.New(basicauth.Config{ - Users: map[string]string{ - "admin": "password", - }, +var BasicAuth = basicauth.Default(map[string]string{ + "admin": "password", }) diff --git a/_examples/mvc/repository/web/middleware/basicauth.go b/_examples/mvc/repository/web/middleware/basicauth.go index 5d61fc72..8cda6629 100644 --- a/_examples/mvc/repository/web/middleware/basicauth.go +++ b/_examples/mvc/repository/web/middleware/basicauth.go @@ -5,8 +5,6 @@ package middleware import "github.com/kataras/iris/v12/middleware/basicauth" // BasicAuth middleware sample. -var BasicAuth = basicauth.New(basicauth.Config{ - Users: map[string]string{ - "admin": "password", - }, +var BasicAuth = basicauth.Default(map[string]string{ + "admin": "password", }) diff --git a/_examples/routing/rewrite/main.go b/_examples/routing/rewrite/main.go index 9050e0d3..d6d48ee3 100644 --- a/_examples/routing/rewrite/main.go +++ b/_examples/routing/rewrite/main.go @@ -17,22 +17,21 @@ func main() { newtest := app.Subdomain("newtest") newtest.Get("/", newTestIndex) - newtest.Get("/", newTestAbout) + newtest.Get("/about", newTestAbout) redirects := rewrite.Load("redirects.yml") app.WrapRouter(redirects) - // http://mydomain.com:8080/seo/about -> http://www.mydomain.com:8080/about - // http://test.mydomain.com:8080 -> http://newtest.mydomain.com:8080 - // http://test.mydomain.com:8080/seo/about -> http://newtest.mydomain.com:8080/about - // http://localhost:8080/seo -> http://localhost:8080 - // http://localhost:8080/about - // http://localhost:8080/docs/v12/hello -> http://localhost:8080/docs - // http://localhost:8080/docs/v12some -> http://localhost:8080/docs - // http://localhost:8080/oldsome -> http://localhost:8080 - // http://localhost:8080/oldindex/random -> http://localhost:8080 - // http://localhost:8080/users.json -> http://localhost:8080/users.json - // ^ (but with an internal ?format=json, client can't see it) + // http://mydomain.com:8080/seo/about -> http://www.mydomain.com:8080/about + // http://test.mydomain.com:8080 -> http://newtest.mydomain.com:8080 + // http://test.mydomain.com:8080/seo/about -> http://newtest.mydomain.com:8080/about + // http://mydomain.com:8080/seo -> http://www.mydomain.com:8080 + // http://mydomain.com:8080/about + // http://mydomain.com:8080/docs/v12/hello -> http://www.mydomain.com:8080/docs + // http://mydomain.com:8080/docs/v12some -> http://www.mydomain.com:8080/docs + // http://mydomain.com:8080/oldsome -> http://www.mydomain.com:8080 + // http://mydomain.com:8080/oldindex/random -> http://www.mydomain.com:8080 + // http://mydomain.com:8080/users.json -> http://www.mydomain.com:8080/users?format=json app.Listen(":8080") } diff --git a/_examples/routing/rewrite/redirects.yml b/_examples/routing/rewrite/redirects.yml index 546baccf..389bb522 100644 --- a/_examples/routing/rewrite/redirects.yml +++ b/_examples/routing/rewrite/redirects.yml @@ -21,4 +21,4 @@ RedirectMatch: # REDIRECT_CODE_DIGITS | PATTERN_REGEX | TARGET_REPL # Redirects root domain to www. # Creation of a www subdomain inside the Application is unnecessary, # all requests are handled by the root Application itself. -PrimarySubdomain: www \ No newline at end of file +PrimarySubdomain: www diff --git a/_examples/testing/httptest/main.go b/_examples/testing/httptest/main.go index c86fc7fc..a2bb3101 100644 --- a/_examples/testing/httptest/main.go +++ b/_examples/testing/httptest/main.go @@ -8,11 +8,11 @@ import ( func newApp() *iris.Application { app := iris.New() - authConfig := basicauth.Config{ - Users: map[string]string{"myusername": "mypassword"}, + opts := basicauth.Options{ + Allow: basicauth.AllowUsers(map[string]string{"myusername": "mypassword"}), } - authentication := basicauth.New(authConfig) + authentication := basicauth.New(opts) // or just: basicauth.Default(map...) app.Get("/", func(ctx iris.Context) { ctx.Redirect("/admin") }) @@ -36,7 +36,9 @@ func h(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. - + // OR: + // ctx.User().GetUsername() + // ctx.User().GetPassword() ctx.Writef("%s %s:%s", ctx.Path(), username, password) } diff --git a/context/context.go b/context/context.go index 71498010..aaffe103 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)) } @@ -1792,7 +1793,7 @@ func (ctx *Context) PostValues(name string) ([]string, error) { // See ErrEmptyForm, ErrNotFound and ErrEmptyFormField respectfully. func (ctx *Context) PostValueMany(name string) (string, error) { values, err := ctx.PostValues(name) - if err != nil { + if err != nil || len(values) == 0 { return "", err } @@ -1805,14 +1806,11 @@ func (ctx *Context) PostValueMany(name string) (string, error) { // If not found then "def" is returned instead. func (ctx *Context) PostValueDefault(name string, def string) string { values, err := ctx.PostValues(name) - if err != nil { + if err != nil || len(values) == 0 { return def // it returns "def" even if it's empty here. } - if len(values) > 0 { - return values[len(values)-1] - } - return def + return values[len(values)-1] } // PostValue returns the last parsed form data from POST, PATCH, @@ -1835,8 +1833,8 @@ func (ctx *Context) PostValueTrim(name string) string { // See ErrEmptyForm, ErrNotFound and ErrEmptyFormField respectfully. func (ctx *Context) PostValueInt(name string) (int, error) { values, err := ctx.PostValues(name) - if err != nil { - return -1, err + if err != nil || len(values) == 0 { + return 0, err } return strconv.Atoi(values[len(values)-1]) @@ -1860,8 +1858,8 @@ func (ctx *Context) PostValueIntDefault(name string, def int) int { // See ErrEmptyForm, ErrNotFound and ErrEmptyFormField respectfully. func (ctx *Context) PostValueInt64(name string) (int64, error) { values, err := ctx.PostValues(name) - if err != nil { - return -1, err + if err != nil || len(values) == 0 { + return 0, err } return strconv.ParseInt(values[len(values)-1], 10, 64) @@ -1885,8 +1883,8 @@ func (ctx *Context) PostValueInt64Default(name string, def int64) int64 { // See ErrEmptyForm, ErrNotFound and ErrEmptyFormField respectfully. func (ctx *Context) PostValueFloat64(name string) (float64, error) { values, err := ctx.PostValues(name) - if err != nil { - return -1, err + if err != nil || len(values) == 0 { + return 0, err } return strconv.ParseFloat(values[len(values)-1], 64) @@ -1911,7 +1909,7 @@ func (ctx *Context) PostValueFloat64Default(name string, def float64) float64 { // See ErrEmptyForm, ErrNotFound and ErrEmptyFormField respectfully. func (ctx *Context) PostValueBool(name string) (bool, error) { values, err := ctx.PostValues(name) - if err != nil { + if err != nil || len(values) == 0 { return false, err } @@ -4726,7 +4724,7 @@ func (ctx *Context) UpsertCookie(cookie *http.Cookie, options ...CookieOption) b // you can change it or simple, use the SetCookie for more control. // // See `CookieExpires` and `AddCookieOptions` for more. -var SetCookieKVExpiration = time.Duration(8760) * time.Hour +var SetCookieKVExpiration = 8760 * time.Hour // SetCookieKV adds a cookie, requires the name(string) and the value(string). // @@ -5343,7 +5341,15 @@ const userContextKey = "iris.user" // SetUser sets a value as a User for this request. // It's used by auth middlewares as a common // method to provide user information to the -// next handlers in the chain +// next handlers in the chain. +// +// The "i" input argument can be: +// - A value which completes the User interface +// - A map[string]interface{}. +// - A value which does not complete the whole User interface +// - A value which does not complete the User interface at all +// (only its `User().GetRaw` method is available). +// // Look the `User` method to retrieve it. func (ctx *Context) SetUser(i interface{}) error { if i == nil { @@ -5371,6 +5377,9 @@ func (ctx *Context) SetUser(i interface{}) error { } // User returns the registered User of this request. +// To get the original value (even if a value set by SetUser does not implement the User interface) +// use its GetRaw method. +// / // See `SetUser` too. func (ctx *Context) User() User { if v := ctx.values.Get(userContextKey); v != nil { diff --git a/context/context_user.go b/context/context_user.go index 893b86d6..794b78a9 100644 --- a/context/context_user.go +++ b/context/context_user.go @@ -33,6 +33,8 @@ var ErrNotSupported = errors.New("not supported") // - UserMap (a wrapper by SetUser) // - UserPartial (a wrapper by SetUser) type User interface { + // GetRaw should return the raw instance of the user, if supported. + GetRaw() (interface{}, error) // GetAuthorization should return the authorization method, // e.g. Basic Authentication. GetAuthorization() (string, error) @@ -83,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"` @@ -92,6 +94,11 @@ type SimpleUser struct { var _ User = (*SimpleUser)(nil) +// GetRaw returns itself. +func (u *SimpleUser) GetRaw() (interface{}, error) { + return u, nil +} + // GetAuthorization returns the authorization method, // e.g. Basic Authentication. func (u *SimpleUser) GetAuthorization() (string, error) { @@ -179,6 +186,11 @@ type UserMap Map var _ User = UserMap{} +// GetRaw returns the underline map. +func (u UserMap) GetRaw() (interface{}, error) { + return Map(u), nil +} + // GetAuthorization returns the authorization or Authorization value of the map. func (u UserMap) GetAuthorization() (string, error) { return u.str("authorization") @@ -292,11 +304,17 @@ type ( GetID() string } - userGetUsername interface { + // UserGetUsername interface which + // requires a single method to complete + // a User on Context.SetUser. + UserGetUsername interface { GetUsername() string } - userGetPassword interface { + // UserGetPassword interface which + // requires a single method to complete + // a User on Context.SetUser. + UserGetPassword interface { GetPassword() string } @@ -319,78 +337,82 @@ type ( // UserPartial is a User. // It's a helper which wraps a struct value that // 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"` } ) var _ User = (*UserPartial)(nil) func newUserPartial(i interface{}) *UserPartial { - containsAtLeastOneMethod := false + if i == nil { + return nil + } + p := &UserPartial{Raw: i} if u, ok := i.(userGetAuthorization); ok { p.userGetAuthorization = u - containsAtLeastOneMethod = true } if u, ok := i.(userGetAuthorizedAt); ok { p.userGetAuthorizedAt = u - containsAtLeastOneMethod = true } if u, ok := i.(userGetID); ok { p.userGetID = u - containsAtLeastOneMethod = true } - if u, ok := i.(userGetUsername); ok { - p.userGetUsername = u - containsAtLeastOneMethod = true + if u, ok := i.(UserGetUsername); ok { + p.UserGetUsername = u } - if u, ok := i.(userGetPassword); ok { - p.userGetPassword = u - containsAtLeastOneMethod = true + if u, ok := i.(UserGetPassword); ok { + p.UserGetPassword = u } if u, ok := i.(userGetEmail); ok { p.userGetEmail = u - containsAtLeastOneMethod = true } if u, ok := i.(userGetRoles); ok { p.userGetRoles = u - containsAtLeastOneMethod = true } if u, ok := i.(userGetToken); ok { p.userGetToken = u - containsAtLeastOneMethod = true } if u, ok := i.(userGetField); ok { p.userGetField = u - containsAtLeastOneMethod = true } - if !containsAtLeastOneMethod { - return nil - } + // if !containsAtLeastOneMethod { + // return nil + // } return p } +// GetRaw returns the original raw instance of the user. +func (u *UserPartial) GetRaw() (interface{}, error) { + if u == nil { + return nil, ErrNotSupported + } + + return u.Raw, nil +} + // GetAuthorization should return the authorization method, // e.g. Basic Authentication. func (u *UserPartial) GetAuthorization() (string, error) { @@ -422,7 +444,7 @@ func (u *UserPartial) GetID() (string, error) { // GetUsername should return the name of the User. func (u *UserPartial) GetUsername() (string, error) { - if v := u.userGetUsername; v != nil { + if v := u.UserGetUsername; v != nil { return v.GetUsername(), nil } @@ -432,7 +454,7 @@ func (u *UserPartial) GetUsername() (string, error) { // GetPassword should return the encoded or raw password // (depends on the implementation) of the User. func (u *UserPartial) GetPassword() (string, error) { - if v := u.userGetPassword; v != nil { + if v := u.UserGetPassword; v != nil { return v.GetPassword(), nil } diff --git a/go.mod b/go.mod index 6c51a433..93e5e106 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 github.com/fatih/structs v1.1.0 github.com/flosch/pongo2/v4 v4.0.0 - github.com/go-redis/redis/v8 v8.3.3 + github.com/go-redis/redis/v8 v8.4.0 github.com/google/uuid v1.1.2 github.com/hashicorp/go-version v1.2.1 github.com/iris-contrib/httpexpect/v2 v2.0.5 @@ -32,12 +32,12 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 github.com/schollz/closestmatch v2.1.0+incompatible github.com/tdewolff/minify/v2 v2.9.10 - github.com/vmihailenco/msgpack/v5 v5.0.0-rc.3 + github.com/vmihailenco/msgpack/v5 v5.0.0 github.com/yosssi/ace v0.0.5 go.etcd.io/bbolt v1.3.5 - golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 - golang.org/x/net v0.0.0-20201027133719-8eef5233e2a1 - golang.org/x/sys v0.0.0-20201028094953-708e7fb298ac + golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392 + golang.org/x/net v0.0.0-20201110031124-69a78807bb2b + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 golang.org/x/text v0.3.4 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e google.golang.org/protobuf v1.25.0 diff --git a/middleware/basicauth/basicauth.go b/middleware/basicauth/basicauth.go index a1c83b5c..b1eb8cf9 100644 --- a/middleware/basicauth/basicauth.go +++ b/middleware/basicauth/basicauth.go @@ -1,205 +1,547 @@ -// Package basicauth provides http basic authentication via middleware. See _examples/auth/basicauth package basicauth -/* -Test files: - - ../../_examples/auth/basicauth/main_test.go - - ./basicauth_test.go -*/ - import ( - "encoding/base64" + stdContext "context" + "net/http" + "net/url" "strconv" + "strings" "sync" "time" "github.com/kataras/iris/v12/context" + "github.com/kataras/iris/v12/sessions" ) func init() { context.SetHandlerName("iris/middleware/basicauth.*", "iris.basicauth") } -const authorizationType = "Basic Authentication" - -type ( - encodedUser struct { - HeaderValue string - Username string - Password string - logged bool - forceLogout bool // in order to be able to invalidate and use a redirect response. - authorizedAt time.Time // when from !logged to logged. - expires time.Time - mu sync.RWMutex - } - - basicAuthMiddleware struct { - config *Config - // these are filled from the config.Users map at the startup - auth []*encodedUser - realmHeaderValue string - - // The below can be removed but they are here because on the future we may add dynamic options for those two fields, - // it is a bit faster to check the b.$bool as well. - expireEnabled bool // if the config.Expires is a valid date, default is disabled. - askHandlerEnabled bool // if the config.OnAsk is not nil, defaults to false. - } +const ( + // 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" + proxyAuthenticateHeaderKey = "Proxy-Authenticate" + authorizationHeaderKey = "Authorization" + 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) -// New accepts basicauth.Config and returns a new Handler -// which will ask the client for basic auth (username, password), -// validate that and if valid continues to the next handler, otherwise -// throws a StatusUnauthorized http error code. +// 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. // -// Use the `Context.User` method to retrieve the stored user. -func New(c Config) context.Handler { - config := DefaultConfig() - if c.Realm != "" { - config.Realm = c.Realm - } - config.Users = c.Users - config.Expires = c.Expires - config.OnAsk = c.OnAsk - - b := &basicAuthMiddleware{config: &config} - b.init() - return b.Serve +// Usage: +// opts := Options { ... } +// auth := New(opts) +type Options struct { + // 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), + // 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 + // 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 + // 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 + // 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 + // If not empty then this session key will be used to store + // the current tries of login failures. If not a session manager + // was registered then the application will log an error. + // Note that this field has a priority over the MaxTriesCookie. + MaxTriesSession 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 + // 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 } -// Default accepts only the users and returns a new Handler -// which will ask the client for basic auth (username, password), -// validate that and if valid continues to the next handler, otherwise -// throws a StatusUnauthorized http error code. -func Default(users map[string]string) context.Handler { - c := DefaultConfig() - c.Users = users - return New(c) +// 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 } -func (b *basicAuthMiddleware) init() { - // pass the encoded users from the user's config's Users value - b.auth = make([]*encodedUser, 0, len(b.config.Users)) +// 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 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 + askCode int + authorizationHeader string + authenticateHeader string + // built based on realm field. + authenticateHeaderValue string - for k, v := range b.config.Users { - fullUser := k + ":" + v - header := "Basic " + base64.StdEncoding.EncodeToString([]byte(fullUser)) - b.auth = append(b.auth, &encodedUser{ - HeaderValue: header, - Username: k, - Password: v, - logged: false, - expires: DefaultExpireTime, - }) - } - - // set the auth realm header's value - b.realmHeaderValue = "Basic realm=" + strconv.Quote(b.config.Realm) - - b.expireEnabled = b.config.Expires > 0 - b.askHandlerEnabled = b.config.OnAsk != nil + // 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 } -func (b *basicAuthMiddleware) findAuth(headerValue string) (*encodedUser, bool) { - if headerValue != "" { - for _, user := range b.auth { - if user.HeaderValue == headerValue { - return user, true - } - } +// 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 = http.StatusUnauthorized + authorizationHeader = authorizationHeaderKey + authenticateHeader = authenticateHeaderKey + authenticateHeaderValue = "Basic" + ) + + if opts.Allow == nil { + panic("BasicAuth: Allow field is required") } - return nil, false + if opts.Realm != "" { + authenticateHeaderValue += " realm=" + strconv.Quote(opts.Realm) + } + + if opts.Proxy { + askCode = http.StatusProxyAuthRequired + authenticateHeader = proxyAuthenticateHeaderKey + authorizationHeader = proxyAuthorizationHeaderKey + } + + if opts.MaxTries > 0 && opts.MaxTriesCookie == "" { + opts.MaxTriesCookie = DefaultMaxTriesCookie + } + + if opts.ErrorHandler == nil { + opts.ErrorHandler = DefaultErrorHandler + } + + 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 } -func (b *basicAuthMiddleware) askForCredentials(ctx *context.Context) { - ctx.Header("WWW-Authenticate", b.realmHeaderValue) - ctx.StatusCode(401) - if b.askHandlerEnabled { - b.config.OnAsk(ctx) +// 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, + Allow: AllowUsers(users, userOpts...), } + return New(opts) } -// Serve the actual basic authentication middleware. -// Use the Context.User method to retrieve the stored user. -func (b *basicAuthMiddleware) Serve(ctx *context.Context) { - auth, found := b.findAuth(ctx.GetHeader("Authorization")) - if !found || auth.forceLogout { - if auth != nil { - auth.mu.Lock() - auth.forceLogout = false - auth.mu.Unlock() - } - - b.askForCredentials(ctx) - ctx.StopExecution() - return - // don't continue to the next handler +// 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, + Allow: AllowUsersFile(jsonOrYamlFilename, userOpts...), } + return New(opts) +} - auth.mu.RLock() - logged := auth.logged - auth.mu.RUnlock() - if !logged { - auth.mu.Lock() - auth.authorizedAt = time.Now() - auth.mu.Unlock() - } - - // all ok - if b.expireEnabled { - if !logged { - auth.mu.Lock() - auth.expires = auth.authorizedAt.Add(b.config.Expires) - auth.logged = true - auth.mu.Unlock() - } - - auth.mu.RLock() - expired := time.Now().After(auth.expires) - auth.mu.RUnlock() - if expired { - auth.mu.Lock() - auth.logged = false - auth.forceLogout = false - auth.mu.Unlock() - b.askForCredentials(ctx) // ask for authentication again - ctx.StopExecution() +func (b *BasicAuth) getCurrentTries(ctx *context.Context) (tries int) { + if key := b.opts.MaxTriesSession; key != "" { + if sess := sessions.Get(ctx); sess != nil { + tries = sess.GetIntDefault(key, 0) + } else { + ctx.Application().Logger().Error("basicauth: getCurrentTries: session key: %s but no session manager is registered", key) return } - } - - if !b.config.DisableContextUser { - ctx.SetLogoutFunc(b.Logout) - - auth.mu.RLock() - user := &context.SimpleUser{ - Authorization: authorizationType, - AuthorizedAt: auth.authorizedAt, - Username: auth.Username, - Password: auth.Password, + } else { + cookie := ctx.GetCookie(b.opts.MaxTriesCookie) + if cookie != "" { + tries, _ = strconv.Atoi(cookie) } - auth.mu.RUnlock() - ctx.SetUser(user) } - ctx.Next() // continue + return } -// Logout sends a 401 so the browser/client can invalidate the -// Basic Authentication and also sets the underline user's logged field to false, -// so its expiration resets when re-ask for credentials. -// -// End-developers should call the `Context.Logout()` method -// to fire this method as this structure is hidden. -func (b *basicAuthMiddleware) Logout(ctx *context.Context) { - ctx.StatusCode(401) - if auth, found := b.findAuth(ctx.GetHeader("Authorization")); found { - auth.mu.Lock() - auth.logged = false - auth.forceLogout = true - auth.mu.Unlock() +func (b *BasicAuth) setCurrentTries(ctx *context.Context, tries int) { + if key := b.opts.MaxTriesSession; key != "" { + if sess := sessions.Get(ctx); sess != nil { + sess.Set(key, tries) + } else { + ctx.Application().Logger().Error("basicauth: setCurrentTries: session key: %s but no session manager is registered", key) + return + } + } else { + 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) { + if key := b.opts.MaxTriesSession; key != "" { + if sess := sessions.Get(ctx); sess != nil { + sess.Delete(key) + } else { + ctx.Application().Logger().Error("basicauth: resetCurrentTries: session key: %s but no session manager is registered", key) + return + } + } else { + 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) { + ctx.Application().Logger().Debug(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 (e.g. browser cancel button on user prompt). + b.handleError(ctx, ErrCredentialsMissing{ + Header: header, + AuthenticateHeader: b.authenticateHeader, + AuthenticateHeaderValue: b.authenticateHeaderValue, + Code: b.askCode, + }) + 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.handleError(ctx, ErrCredentialsForbidden{ + Username: username, + Password: password, + Tries: tries, + Age: b.opts.MaxAge, + }) + return + } + } + + b.handleError(ctx, ErrCredentialsInvalid{ + Username: username, + Password: password, + CurrentTries: tries, + AuthenticateHeader: b.authenticateHeader, + AuthenticateHeaderValue: b.authenticateHeaderValue, + Code: b.askCode, + }) + return + } + + if tries > 0 { + // had failures but it's ok, reset the tries on success. + b.resetCurrentTries(ctx) + } + + 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.handleError(ctx, ErrCredentialsExpired{ + Username: username, + Password: password, + AuthenticateHeader: b.authenticateHeader, + AuthenticateHeaderValue: b.authenticateHeaderValue, + Code: b.askCode, + }) + return + } + + // It's ok, find the time authorized to fill the user below, if necessary. + authorizedAt = expiresAt.Add(-b.opts.MaxAge) + } + } 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() + } + + 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, + } + } + + // 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) + + 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 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) + } + // 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() + } +} + +// 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() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + b.gc() + } + } +} + +// 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 || expiresAt.Before(now) { + markedForDeletion = append(markedForDeletion, fullUser) + } + } + 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 +} diff --git a/middleware/basicauth/basicauth_test.go b/middleware/basicauth/basicauth_test.go index 2eff61dc..e19147f2 100644 --- a/middleware/basicauth/basicauth_test.go +++ b/middleware/basicauth/basicauth_test.go @@ -18,7 +18,13 @@ func TestBasicAuthUseRouter(t *testing.T) { "admin": "admin", } - app.UseRouter(basicauth.Default(users)) + auth := basicauth.New(basicauth.Options{ + Allow: basicauth.AllowUsers(users), + Realm: basicauth.DefaultRealm, + MaxTries: 1, + }) + + app.UseRouter(auth) app.Get("/user_json", func(ctx iris.Context) { ctx.JSON(ctx.User()) @@ -80,9 +86,9 @@ func TestBasicAuthUseRouter(t *testing.T) { e.GET("/").Expect().Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") // Test invalid auth. e.GET("/").WithBasicAuth(username, "invalid_password").Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") + Status(httptest.StatusForbidden) e.GET("/").WithBasicAuth("invaid_username", password).Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") + Status(httptest.StatusForbidden) // Test different method, it should pass the authentication (no stop on 401) // but it doesn't fire the GET route, instead it gives 405. @@ -97,9 +103,9 @@ func TestBasicAuthUseRouter(t *testing.T) { e.GET("/notfound").Expect().Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") // Test invalid auth. e.GET("/notfound").WithBasicAuth(username, "invalid_password").Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") + Status(httptest.StatusForbidden) e.GET("/notfound").WithBasicAuth("invaid_username", password).Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") + Status(httptest.StatusForbidden) // Test subdomain inherited. sub := e.Builder(func(req *httptest.Request) { @@ -114,9 +120,9 @@ func TestBasicAuthUseRouter(t *testing.T) { sub.GET("/").Expect().Status(httptest.StatusUnauthorized) // Test invalid auth. sub.GET("/").WithBasicAuth(username, "invalid_password").Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") + Status(httptest.StatusForbidden) sub.GET("/").WithBasicAuth("invaid_username", password).Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") + Status(httptest.StatusForbidden) // Test pass the authentication but route not found. sub.GET("/notfound").WithBasicAuth(username, password).Expect(). @@ -126,9 +132,9 @@ func TestBasicAuthUseRouter(t *testing.T) { sub.GET("/notfound").Expect().Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") // Test invalid auth. sub.GET("/notfound").WithBasicAuth(username, "invalid_password").Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") + Status(httptest.StatusForbidden) sub.GET("/notfound").WithBasicAuth("invaid_username", password).Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") + Status(httptest.StatusForbidden) // Test a reset-ed Party with a single one UseRouter // which writes on matched routes and reset and send the error on errors. diff --git a/middleware/basicauth/config.go b/middleware/basicauth/config.go deleted file mode 100644 index 70396215..00000000 --- a/middleware/basicauth/config.go +++ /dev/null @@ -1,59 +0,0 @@ -package basicauth - -import ( - "time" - - "github.com/kataras/iris/v12/context" -) - -const ( - // DefaultBasicAuthRealm is "Authorization Required" - DefaultBasicAuthRealm = "Authorization Required" -) - -// DefaultExpireTime zero time -var DefaultExpireTime time.Time // 0001-01-01 00:00:00 +0000 UTC - -// Config the configs for the basicauth middleware -type Config struct { - // Users a map of login and the value (username/password) - Users map[string]string - // Realm http://tools.ietf.org/html/rfc2617#section-1.2. Default is "Authorization Required" - Realm string - // Expires expiration duration, default is 0 never expires. - Expires time.Duration - - // OnAsk fires each time the server asks to the client for credentials in order to gain access and continue to the next handler. - // - // You could also ignore this option and - // - just add a listener for unauthorized status codes with: - // `app.OnErrorCode(iris.StatusUnauthorized, unauthorizedWantsAccessHandler)` - // - or register a middleware which will force `ctx.Next/or direct call` - // the basicauth middleware and check its `ctx.GetStatusCode()`. - // - // However, this option is very useful when you want the framework to fire a handler - // ONLY when the Basic Authentication sends an `iris.StatusUnauthorized`, - // and free the error code listener to catch other types of unauthorized access, i.e Kerberos. - // Also with this one, not recommended at all but, you are able to "force-allow" other users by calling the `ctx.StatusCode` inside this handler; - // i.e when it is possible to create authorized users dynamically but - // if that is the case then you should go with something like sessions instead of basic authentication. - // - // Usage: basicauth.New(basicauth.Config{..., OnAsk: unauthorizedWantsAccessViaBasicAuthHandler}) - // - // Defaults to nil. - OnAsk context.Handler - - // DisableContextUser disables the registration of the custom basicauth Context.Logout - // and the User. - DisableContextUser bool -} - -// DefaultConfig returns the default configs for the BasicAuth middleware -func DefaultConfig() Config { - return Config{make(map[string]string), DefaultBasicAuthRealm, 0, nil, false} -} - -// User returns the user from context key same as ctx.Request().BasicAuth(). -func (c Config) User(ctx *context.Context) (string, string, bool) { - return ctx.Request().BasicAuth() -} 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/header.go b/middleware/basicauth/header.go new file mode 100644 index 00000000..eadb6b8a --- /dev/null +++ b/middleware/basicauth/header.go @@ -0,0 +1,88 @@ +package basicauth + +import ( + "encoding/base64" + "strings" +) + +const ( + spaceChar = ' ' + colonChar = ':' + colonLiteral = string(colonChar) + basicLiteral = "Basic" + basicSpaceLiteral = "Basic " + basicSpaceLiteralLen = len(basicSpaceLiteral) +) + +// The username and password are combined with a single colon (:). +// This means that the username itself cannot contain a colon. +// URL encoding (e.g. https://Aladdin:OpenSesame@www.example.com/index.html) +// has been deprecated by rfc3986. +func encodeHeader(username, password string) (string, bool) { + if strings.Contains(username, colonLiteral) || strings.Contains(password, colonLiteral) { + return "", false + } + fullUser := []byte(username + colonLiteral + password) + header := basicSpaceLiteral + base64.StdEncoding.EncodeToString(fullUser) + + return header, true +} + +// Like net/http.parseBasicAuth +func decodeHeader(header string) (fullUser, username, password string, ok bool) { + if len(header) < basicSpaceLiteralLen || !strings.EqualFold(header[:basicSpaceLiteralLen], basicSpaceLiteral) { + return + } + + c, err := base64.StdEncoding.DecodeString(header[basicSpaceLiteralLen:]) + if err != nil { + return + } + + cs := string(c) + s := strings.IndexByte(cs, colonChar) + if s < 0 { + return + } + return cs, cs[:s], cs[s+1:], true + + /* + for i := 0; i < n; i++ { + if header[i] == spaceChar { + prefix := header[:i] + if prefix != basicLiteral { + return + } + + if n <= i+1 { + return + } + + decodedFullUser, err := base64.RawStdEncoding.DecodeString(header[i+1:]) + if err != nil { + return + } + + fullUser = string(decodedFullUser) + break + } + } + + n = len(fullUser) + for i := n - 1; i > -1; i-- { + if fullUser[i] == colonChar { + username = fullUser[:i] + password = fullUser[i+1:] + + if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" { + ok = false + } else { + ok = true + } + + return + } + } + + return*/ +} diff --git a/middleware/basicauth/header_test.go b/middleware/basicauth/header_test.go new file mode 100644 index 00000000..f365da7b --- /dev/null +++ b/middleware/basicauth/header_test.go @@ -0,0 +1,103 @@ +package basicauth + +import "testing" + +func TestHeaderEncode(t *testing.T) { + var tests = []struct { + username string + password string + header string + ok bool + }{ + { + username: "user", + password: "pass", + header: "Basic dXNlcjpwYXNz", + ok: true, + }, + { + username: "user", + password: "p:(notallowed)ass", + header: "", + ok: false, + }, + { + username: "123u%ser", + password: "pass132$", + header: "Basic MTIzdSVzZXI6cGFzczEzMiQ=", + ok: true, + }, + } + + for i, tt := range tests { + got, ok := encodeHeader(tt.username, tt.password) + if tt.ok != ok { + t.Fatalf("[%d] expected: %v but got: %v (username=%s,password=%s)", i, tt.ok, ok, tt.username, tt.password) + } + if tt.header != got { + t.Fatalf("[%d] expected result header: %q but got: %q", i, tt.header, got) + } + } +} + +func TestHeaderDecode(t *testing.T) { + var tests = []struct { + header string + ok bool + username string + password string + }{ + { + header: "Basic dXNlcjpwYXNz", + ok: true, + username: "user", + password: "pass", + }, + { + header: "dXNlcjpwYXNz", + ok: false, + }, + { + header: "Basic ", + ok: false, + }, + { + header: "Basic dXNlcjp", + ok: false, + }, + { + header: "dXNlcjpwYXNz Basic", + ok: false, + }, + { + header: "dXNlcjpwYXNzBasic", + ok: false, + }, + } + + for i, tt := range tests { + fullUser, username, password, ok := decodeHeader(tt.header) + if expected, got := tt.ok, ok; expected != got { + t.Fatalf("[%d] expected: %v but got: %v (header=%s)", i, expected, got, tt.header) + } + + if expected, got := tt.username, username; expected != got { + t.Fatalf("[%d] expected username: %q but got: %q", i, expected, got) + } + + if expected, got := tt.password, password; expected != got { + t.Fatalf("[%d] expected password: %q but got: %q", i, expected, got) + } + + if tt.username != "" || tt.password != "" { + if expected, got := tt.username+colonLiteral+tt.password, fullUser; expected != got { + t.Fatalf("[%d] expected username:password to be: %q but got: %q", i, expected, got) + } + } else { + if fullUser != "" { + t.Fatalf("[%d] expected username:password to be empty but got: %q", i, fullUser) + } + } + + } +} diff --git a/middleware/basicauth/user.go b/middleware/basicauth/user.go new file mode 100644 index 00000000..4f66c1dc --- /dev/null +++ b/middleware/basicauth/user.go @@ -0,0 +1,300 @@ +package basicauth + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "reflect" + "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 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)) + return err == nil + } +} + +func toUserAuthOptions(opts []UserAuthOption) (options UserAuthOptions) { + for _, opt := range opts { + opt(&options) + } + + if options.ComparePassword == nil { + options.ComparePassword = func(stored, userPassword string) bool { + return stored == userPassword + } + } + + return options +} + +// 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. + type user struct { + password string + ref interface{} + } + cp := make(map[string]*user) + + v := reflect.Indirect(reflect.ValueOf(users)) + switch v.Kind() { + case reflect.Slice: + for i := 0; i < v.Len(); i++ { + elem := v.Index(i).Interface() + // MUST contain a username and password. + username, password, ok := extractUsernameAndPassword(elem) + if !ok { + continue + } + + cp[username] = &user{ + password: password, + ref: elem, + } + } + case reflect.Map: + elem := v.Interface() + switch m := elem.(type) { + case map[string]string: + return userMap(m, opts...) + case map[string]interface{}: + username, password, ok := mapUsernameAndPassword(m) + if !ok { + break + } + + cp[username] = &user{ + password: password, + ref: m, + } + default: + panic(fmt.Sprintf("unsupported type of map: %T", users)) + } + default: + panic(fmt.Sprintf("unsupported type: %T", users)) + } + + options := toUserAuthOptions(opts) + + return func(_ *context.Context, username, password string) (interface{}, bool) { + if u, ok := cp[username]; ok { // fast map access, + if options.ComparePassword(u.password, password) { + return u.ref, true + } + } + + return nil, false + } +} + +func userMap(usernamePassword map[string]string, opts ...UserAuthOption) AuthFunc { + options := toUserAuthOptions(opts) + + return func(_ *context.Context, username, password string) (interface{}, bool) { + pass, ok := usernamePassword[username] + return nil, ok && options.ComparePassword(pass, password) + } +} + +// 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": ...} + userList []map[string]interface{} + ) + + if err := decodeFile(jsonOrYamlFilename, &usernamePassword, &userList); err != nil { + panic(err) + } + + if len(usernamePassword) > 0 { + // JSON Form: { "$username":"$pass", "$username": "$pass" } + // YAML Form: $username: $pass + // $username: $pass + return userMap(usernamePassword, opts...) + } + + if len(userList) > 0 { + // JSON Form: [{"username": "$username", "password": "$pass", "other_field": ...}, {"username": ...}, ... ] + // YAML Form: + // - username: $username + // password: $password + // other_field: ... + return AllowUsers(userList, opts...) + } + + panic("malformed document file: " + jsonOrYamlFilename) +} + +func decodeFile(src string, dest ...interface{}) error { + data, err := ReadFile(src) + if err != nil { + return err + } + + // We use unmarshal instead of file decoder + // as we may need to read it more than once (dests, see below). + var ( + unmarshal func(data []byte, v interface{}) error + ext string + ) + + if idx := strings.LastIndexByte(src, '.'); idx > 0 { + ext = src[idx:] + } + + switch ext { + case "", ".json": + unmarshal = json.Unmarshal + case ".yml", ".yaml": + unmarshal = yaml.Unmarshal + default: + return fmt.Errorf("unexpected file extension: %s", ext) + } + + var ( + ok bool + lastErr error + ) + + for _, d := range dest { + if err = unmarshal(data, d); err == nil { + ok = true + } else { + lastErr = err + } + } + + if !ok { + return lastErr + } + + return nil // if at least one is succeed we are ok. +} + +func extractUsernameAndPassword(s interface{}) (username, password string, ok bool) { + if s == nil { + return + } + + switch u := s.(type) { + case User: + username = u.GetUsername() + password = u.GetPassword() + ok = username != "" && password != "" + return + case map[string]interface{}: + return mapUsernameAndPassword(u) + default: + b, err := json.Marshal(u) + if err != nil { + return + } + + var m map[string]interface{} + if err = json.Unmarshal(b, &m); err != nil { + return + } + + return mapUsernameAndPassword(m) + } +} + +func mapUsernameAndPassword(m map[string]interface{}) (username, password string, ok bool) { + // type of username: password. + if len(m) == 1 { + for username, v := range m { + if password, ok := v.(string); ok { + ok := username != "" && password != "" + return username, password, ok + } + } + } + + var usernameFound, passwordFound bool + + for k, v := range m { + switch k { + case "username", "Username": + username, usernameFound = v.(string) + case "password", "Password": + password, passwordFound = v.(string) + } + + if usernameFound && passwordFound { + ok = true + break + } + } + + return +} diff --git a/middleware/basicauth/user_test.go b/middleware/basicauth/user_test.go new file mode 100644 index 00000000..3ce80a12 --- /dev/null +++ b/middleware/basicauth/user_test.go @@ -0,0 +1,288 @@ +package basicauth + +import ( + "errors" + "io/ioutil" + "os" + "reflect" + "testing" + + "github.com/kataras/iris/v12/context" + + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" +) + +type IUserRepository interface { + GetByUsernameAndPassword(dest interface{}, username, password string) error +} + +// 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(_ *context.Context, username, password string) (interface{}, bool) { + dest := newUserPtr() + err := repo.GetByUsernameAndPassword(dest, username, password) + if err == nil { + return dest, true + } + + return nil, false + } +} + +type testUser struct { + username string + password string + email string // custom field. +} + +// GetUsername & Getpassword complete the User interface. +func (u *testUser) GetUsername() string { + return u.username +} + +func (u *testUser) GetPassword() string { + return u.password +} + +type testRepo struct { + entries []testUser +} + +// Implements IUserRepository interface. +func (r *testRepo) GetByUsernameAndPassword(dest interface{}, username, password string) error { + for _, e := range r.entries { + if e.username == username && e.password == password { + *dest.(*testUser) = e + return nil + } + } + + return errors.New("invalid credentials") +} + +func TestAllowUserRepository(t *testing.T) { + repo := &testRepo{ + entries: []testUser{ + {username: "kataras", password: "kataras_pass", email: "kataras2006@hotmail.com"}, + }, + } + + allow := UserRepository(repo, func() interface{} { + return new(testUser) + }) + + var tests = []struct { + username string + password string + ok bool + user *testUser + }{ + { + username: "kataras", + password: "kataras_pass", + ok: true, + user: &testUser{username: "kataras", password: "kataras_pass", email: "kataras2006@hotmail.com"}, + }, + { + username: "makis", + password: "makis_password", + ok: false, + }, + } + + for i, tt := range tests { + v, ok := allow(nil, tt.username, tt.password) + + if tt.ok != ok { + t.Fatalf("[%d] expected: %v but got: %v (username=%s,password=%s)", i, tt.ok, ok, tt.username, tt.password) + } + + if !ok { + continue + } + + u, ok := v.(*testUser) + if !ok { + t.Fatalf("[%d] a user should be type of *testUser but got: %#+v (%T)", i, v, v) + } + + if !reflect.DeepEqual(tt.user, u) { + t.Fatalf("[%d] expected user:\n%#+v\nbut got:\n%#+v", i, tt.user, u) + } + } +} + +func TestAllowUsers(t *testing.T) { + users := []User{ + &testUser{username: "kataras", password: "kataras_pass", email: "kataras2006@hotmail.com"}, + } + + allow := AllowUsers(users) + + var tests = []struct { + username string + password string + ok bool + user *testUser + }{ + { + username: "kataras", + password: "kataras_pass", + ok: true, + user: &testUser{username: "kataras", password: "kataras_pass", email: "kataras2006@hotmail.com"}, + }, + { + username: "makis", + password: "makis_password", + ok: false, + }, + } + + for i, tt := range tests { + v, ok := allow(nil, tt.username, tt.password) + + if tt.ok != ok { + t.Fatalf("[%d] expected: %v but got: %v (username=%s,password=%s)", i, tt.ok, ok, tt.username, tt.password) + } + + if !ok { + continue + } + + u, ok := v.(*testUser) + if !ok { + t.Fatalf("[%d] a user should be type of *testUser but got: %#+v (%T)", i, v, v) + } + + if !reflect.DeepEqual(tt.user, u) { + t.Fatalf("[%d] expected user:\n%#+v\nbut got:\n%#+v", i, tt.user, u) + } + } +} + +// Test YAML user loading with b-encrypted passwords. +func TestAllowUsersFile(t *testing.T) { + f, err := ioutil.TempFile("", "*users.yml") + if err != nil { + t.Fatal(err) + } + defer func() { + f.Close() + os.Remove(f.Name()) + }() + + // f.WriteString(` + // - username: kataras + // password: kataras_pass + // age: 27 + // role: admin + // - username: makis + // password: makis_password + // `) + // This form is supported too, although its features are limited (no custom fields): + // f.WriteString(` + // kataras: kataras_pass + // makis: makis_password + // `) + + var tests = []struct { + username string + password string // hashed, auto-filled later on. + inputPassword string + ok bool + user context.Map + }{ + { + username: "kataras", + inputPassword: "kataras_pass", + ok: true, + user: context.Map{"age": 27, "role": "admin"}, // username and password are auto-filled in our tests below. + }, + { + username: "makis", + inputPassword: "makis_password", + ok: true, + user: context.Map{}, + }, + { + username: "invalid", + password: "invalid_pass", + ok: false, + }, + { + username: "notvalid", + password: "", + ok: false, + }, + } + + // Write the tests to the users YAML file. + var usersToWrite []context.Map + for _, tt := range tests { + 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 + + // cannot write it as a stream, write it as a slice. + // enc.Encode(tt.user) + usersToWrite = append(usersToWrite, tt.user) + } + // bcrypt.GenerateFromPassword([]byte("kataras_pass"), bcrypt.DefaultCost) + } + + fileContents, err := yaml.Marshal(usersToWrite) + if err != nil { + t.Fatal(err) + } + f.Write(fileContents) + + // Build the authentication func. + allow := AllowUsersFile(f.Name(), BCRYPT) + for i, tt := range tests { + v, ok := allow(nil, tt.username, tt.inputPassword) + + if tt.ok != ok { + t.Fatalf("[%d] expected: %v but got: %v (username=%s,password=%s,user=%#+v)", i, tt.ok, ok, tt.username, tt.inputPassword, v) + } + + if !ok { + continue + } + + if len(tt.user) == 0 { // when username: password form. + continue + } + + u, ok := v.(context.Map) + if !ok { + t.Fatalf("[%d] a user loaded from external source or file should be alway type of map[string]interface{} but got: %#+v (%T)", i, v, v) + } + + if expected, got := len(tt.user), len(u); expected != got { + t.Fatalf("[%d] expected user map length to be equal, expected: %d but got: %d\n%#+v\n%#+v", i, expected, got, tt.user, u) + } + + for k, v := range tt.user { + if u[k] != v { + t.Fatalf("[%d] expected user map %q to be %q but got: %q", i, k, v, u[k]) + } + } + } + +} + +func mustGeneratePassword(t *testing.T, userPassword string) string { + t.Helper() + hashed, err := bcrypt.GenerateFromPassword([]byte(userPassword), bcrypt.DefaultCost) + if err != nil { + t.Fatal(err) + } + + return string(hashed) +}