mirror of
https://github.com/kataras/iris.git
synced 2025-02-02 07:20:35 +01:00
more features and fix database/mysql:jwt example
This commit is contained in:
parent
4d857ac53f
commit
11e21150d0
|
@ -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`
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -44,12 +44,12 @@ func newApp() *iris.Application {
|
|||
needAuth := app.Party("/admin", auth)
|
||||
{
|
||||
//http://localhost:8080/admin
|
||||
needAuth.Get("/", h)
|
||||
needAuth.Get("/", handler)
|
||||
// http://localhost:8080/admin/profile
|
||||
needAuth.Get("/profile", h)
|
||||
needAuth.Get("/profile", handler)
|
||||
|
||||
// http://localhost:8080/admin/settings
|
||||
needAuth.Get("/settings", h)
|
||||
needAuth.Get("/settings", handler)
|
||||
|
||||
needAuth.Get("/logout", logout)
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ func main() {
|
|||
app.Listen(":8080")
|
||||
}
|
||||
|
||||
func h(ctx iris.Context) {
|
||||
func handler(ctx iris.Context) {
|
||||
// username, password, _ := ctx.Request().BasicAuth()
|
||||
// third parameter it will be always true because the middleware
|
||||
// makes sure for that, otherwise this handler will not be executed.
|
|
@ -25,5 +25,5 @@ func TestBasicAuth(t *testing.T) {
|
|||
|
||||
// with invalid basic auth
|
||||
e.GET("/admin/settings").WithBasicAuth("invalidusername", "invalidpassword").
|
||||
Expect().Status(httptest.StatusForbidden)
|
||||
Expect().Status(httptest.StatusUnauthorized)
|
||||
}
|
18
_examples/auth/basicauth/database/Dockerfile
Normal file
18
_examples/auth/basicauth/database/Dockerfile
Normal file
|
@ -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"]
|
44
_examples/auth/basicauth/database/README.md
Normal file
44
_examples/auth/basicauth/database/README.md
Normal file
|
@ -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.
|
||||
|
||||
<http://localhost:8080>
|
||||
|
||||
```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.
|
32
_examples/auth/basicauth/database/docker-compose.yml
Normal file
32
_examples/auth/basicauth/database/docker-compose.yml
Normal file
|
@ -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
|
8
_examples/auth/basicauth/database/go.mod
Normal file
8
_examples/auth/basicauth/database/go.mod
Normal file
|
@ -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
|
||||
)
|
113
_examples/auth/basicauth/database/main.go
Normal file
113
_examples/auth/basicauth/database/main.go
Normal file
|
@ -0,0 +1,113 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
"github.com/kataras/iris/v12/middleware/basicauth"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql" // lint: mysql driver.
|
||||
)
|
||||
|
||||
// User is just an example structure of a user,
|
||||
// it MUST contain a Username and Password exported fields
|
||||
// or/and complete the basicauth.User interface.
|
||||
type User struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Username string `db:"username" json:"username"`
|
||||
Password string `db:"password" json:"password"`
|
||||
Email string `db:"email" json:"email"`
|
||||
}
|
||||
|
||||
// GetUsername returns the Username field.
|
||||
func (u User) GetUsername() string {
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// GetPassword returns the Password field.
|
||||
func (u User) GetPassword() string {
|
||||
return u.Password
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci",
|
||||
getenv("MYSQL_USER", "user_myapp"),
|
||||
getenv("MYSQL_PASSWORD", "dbpassword"),
|
||||
getenv("MYSQL_HOST", "localhost"),
|
||||
getenv("MYSQL_DATABASE", "myapp"),
|
||||
)
|
||||
db, err := connect(dsn)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Validate a user from database.
|
||||
allowFunc := func(ctx iris.Context, username, password string) (interface{}, bool) {
|
||||
user, err := db.getUserByUsernameAndPassword(context.Background(), username, password)
|
||||
return user, err == nil
|
||||
}
|
||||
|
||||
opts := basicauth.Options{
|
||||
Realm: basicauth.DefaultRealm,
|
||||
ErrorHandler: basicauth.DefaultErrorHandler,
|
||||
Allow: allowFunc,
|
||||
}
|
||||
|
||||
auth := basicauth.New(opts)
|
||||
|
||||
app := iris.New()
|
||||
app.Use(auth)
|
||||
app.Get("/", index)
|
||||
app.Listen(":8080")
|
||||
}
|
||||
|
||||
func index(ctx iris.Context) {
|
||||
user := ctx.User()
|
||||
ctx.JSON(user)
|
||||
}
|
||||
|
||||
func getenv(key string, def string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
type database struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func connect(dsn string) (*database, error) {
|
||||
conn, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = conn.Ping()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &database{conn}, nil
|
||||
}
|
||||
|
||||
func (db *database) getUserByUsernameAndPassword(ctx context.Context, username, password string) (User, error) {
|
||||
query := fmt.Sprintf("SELECT * FROM %s WHERE %s = ? AND %s = ? LIMIT 1", "users", "username", "password")
|
||||
rows, err := db.QueryContext(ctx, query, username, password)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return User{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
var user User
|
||||
err = rows.Scan(&user.ID, &user.Username, &user.Password, &user.Email)
|
||||
return user, err
|
||||
}
|
22
_examples/auth/basicauth/database/migration/db.sql
Normal file
22
_examples/auth/basicauth/database/migration/db.sql
Normal file
|
@ -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;
|
30
_examples/auth/basicauth/users_file_bcrypt/main.go
Normal file
30
_examples/auth/basicauth/users_file_bcrypt/main.go
Normal file
|
@ -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)
|
||||
}
|
12
_examples/auth/basicauth/users_file_bcrypt/users.yml
Normal file
12
_examples/auth/basicauth/users_file_bcrypt/users.yml
Normal file
|
@ -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
|
58
_examples/auth/basicauth/users_list/main.go
Normal file
58
_examples/auth/basicauth/users_list/main.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,6 @@ go 1.15
|
|||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.5.0
|
||||
github.com/kataras/iris/v12 master
|
||||
github.com/kataras/iris/v12 v12.2.0-alpha.0.20201117050536-962ffd67721a
|
||||
github.com/mailgun/groupcache/v2 v2.1.0
|
||||
)
|
||||
|
|
|
@ -11,6 +11,8 @@ import (
|
|||
"github.com/kataras/iris/v12"
|
||||
)
|
||||
|
||||
// $ go build .
|
||||
|
||||
func main() {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci",
|
||||
getenv("MYSQL_USER", "user_myapp"),
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
@ -85,7 +85,7 @@ type SimpleUser struct {
|
|||
AuthorizedAt time.Time `json:"authorized_at,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"-"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Token json.RawMessage `json:"token,omitempty"`
|
||||
|
@ -339,16 +339,16 @@ type (
|
|||
// may or may not complete the whole User interface.
|
||||
// See Context.SetUser.
|
||||
UserPartial struct {
|
||||
Raw interface{}
|
||||
userGetAuthorization
|
||||
userGetAuthorizedAt
|
||||
userGetID
|
||||
UserGetUsername
|
||||
UserGetPassword
|
||||
userGetEmail
|
||||
userGetRoles
|
||||
userGetToken
|
||||
userGetField
|
||||
Raw interface{} `json:"raw"`
|
||||
userGetAuthorization `json:",omitempty"`
|
||||
userGetAuthorizedAt `json:",omitempty"`
|
||||
userGetID `json:",omitempty"`
|
||||
UserGetUsername `json:",omitempty"`
|
||||
UserGetPassword `json:",omitempty"`
|
||||
userGetEmail `json:",omitempty"`
|
||||
userGetRoles `json:",omitempty"`
|
||||
userGetToken `json:",omitempty"`
|
||||
userGetField `json:",omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -2,12 +2,15 @@ package basicauth
|
|||
|
||||
import (
|
||||
stdContext "context"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kataras/iris/v12/context"
|
||||
"github.com/kataras/iris/v12/sessions"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
@ -15,10 +18,20 @@ func init() {
|
|||
}
|
||||
|
||||
const (
|
||||
DefaultRealm = "Authorization Required"
|
||||
// DefaultRealm is the default realm directive value on Default and Load functions.
|
||||
DefaultRealm = "Authorization Required"
|
||||
// DefaultMaxTriesCookie is the default cookie name to store the
|
||||
// current amount of login failures when MaxTries > 0.
|
||||
DefaultMaxTriesCookie = "basicmaxtries"
|
||||
// DefaultCookieMaxAge is the default cookie max age on MaxTries,
|
||||
// when the Options.MaxAge is zero.
|
||||
DefaultCookieMaxAge = time.Hour
|
||||
)
|
||||
|
||||
// cookieExpireDelete may be set on Cookie.Expire for expiring the given cookie.
|
||||
// Note that the MaxAge is set but we set Expires field in order to support very old browsers too.
|
||||
var cookieExpireDelete = time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
|
||||
|
||||
const (
|
||||
authorizationType = "Basic Authentication"
|
||||
authenticateHeaderKey = "WWW-Authenticate"
|
||||
|
@ -27,10 +40,26 @@ const (
|
|||
proxyAuthorizationHeaderKey = "Proxy-Authorization"
|
||||
)
|
||||
|
||||
// AuthFunc accepts the current request and the username and password user inputs
|
||||
// and it should optionally return a user value and report whether the login succeed or not.
|
||||
// Look the Options.Allow field.
|
||||
//
|
||||
// Default implementations are:
|
||||
// AllowUsers and AllowUsersFile functions.
|
||||
type AuthFunc func(ctx *context.Context, username, password string) (interface{}, bool)
|
||||
|
||||
// ErrorHandler should handle the given request credentials failure.
|
||||
// See Options.ErrorHandler and DefaultErrorHandler for details.
|
||||
type ErrorHandler func(ctx *context.Context, err error)
|
||||
|
||||
// Options holds the necessary information that the BasicAuth instance needs to perform.
|
||||
// The only required value is the Allow field.
|
||||
//
|
||||
// Usage:
|
||||
// opts := Options { ... }
|
||||
// auth := New(opts)
|
||||
type Options struct {
|
||||
// Realm http://tools.ietf.org/html/rfc2617#section-1.2.
|
||||
// Realm directive, read http://tools.ietf.org/html/rfc2617#section-1.2 for details.
|
||||
// E.g. "Authorization Required".
|
||||
Realm string
|
||||
// In the case of proxies, the challenging status code is 407 (Proxy Authentication Required),
|
||||
|
@ -40,44 +69,59 @@ type Options struct {
|
|||
// Proxy should be used to gain access to a resource behind a proxy server.
|
||||
// It authenticates the request to the proxy server, allowing it to transmit the request further.
|
||||
Proxy bool
|
||||
// If set to true then any non-https request will immediately
|
||||
// dropped with a 505 status code (StatusHTTPVersionNotSupported) response.
|
||||
//
|
||||
// Defaults to false.
|
||||
HTTPSOnly bool
|
||||
// Allow is the only one required field for the Options type.
|
||||
// Can be customized to validate a username and password combination
|
||||
// and return a user object, e.g. fetch from database.
|
||||
//
|
||||
// There are two available builtin values, the AllowUsers and AllowUsersFile,
|
||||
// both of them decode a static list of users and compares with the user input (see BCRYPT function too).
|
||||
// Usage:
|
||||
// - Allow: AllowUsers(iris.Map{"username": "...", "password": "...", "other_field": ...}, [BCRYPT])
|
||||
// - Allow: AllowUsersFile("users.yml", [BCRYPT])
|
||||
// Look the user.go source file for details.
|
||||
Allow AuthFunc
|
||||
// If greater than zero then the server will send 403 forbidden status code afer MaxTries
|
||||
// of invalid credentials of a specific client consumed (session or cookie based, see MaxTriesCookie).
|
||||
// By default the server will re-ask for credentials on any amount of invalid credentials.
|
||||
MaxTries int
|
||||
// If a session manager is register under the current request,
|
||||
// then this value should be the key of the session storage which
|
||||
// the current tries will be stored. Otherwise
|
||||
// it is the raw cookie name.
|
||||
// The cookie is stored up to the configured MaxAge if greater than zero or for 1 year,
|
||||
// so a forbidden client can request for authentication again after the MaxAge expired.
|
||||
//
|
||||
// Note that, the session way is recommended as the current tries
|
||||
// cannot be modified by the client (unless the client removes the session cookie).
|
||||
// However the raw cookie performs faster. You can always set custom logic
|
||||
// on the Allow field as you have access to the current request Context.
|
||||
// To set custom cookie options use the `Context.AddCookieOptions(options ...iris.CookieOption)`
|
||||
// before the basic auth middleware.
|
||||
//
|
||||
// If MaxTries > 0 then it defaults to "basicmaxtries".
|
||||
// The MaxTries should be set to greater than zero.
|
||||
MaxTriesCookie string
|
||||
// If not nil runs after 401 (or 407 if proxy is enabled) status code.
|
||||
// Can be used to set custom response for unauthenticated clients.
|
||||
OnAsk context.Handler
|
||||
// If not nil runs after the 403 forbidden status code (when Allow returned false and MaxTries consumed).
|
||||
// Can be used to set custom response when client tried to access a resource with invalid credentials.
|
||||
OnForbidden context.Handler
|
||||
// MaxAge sets expiration duration for the in-memory credentials map.
|
||||
// By default an old map entry will be removed when the user visits a page.
|
||||
// In order to remove old entries automatically please take a look at the `GC` option too.
|
||||
//
|
||||
// Usage:
|
||||
// MaxAge: 30*time.Minute
|
||||
// MaxAge: 30 * time.Minute
|
||||
MaxAge time.Duration
|
||||
// If greater than zero then the server will send 403 forbidden status code afer
|
||||
// MaxTries amount of sign in failures (see MaxTriesCookie).
|
||||
// Note that the client can modify the cookie and its value,
|
||||
// do NOT depend for any type of custom domain logic based on this field.
|
||||
// By default the server will re-ask for credentials on invalid credentials, each time.
|
||||
MaxTries int
|
||||
// MaxTriesCookie is the cookie name the middleware uses to
|
||||
// store the failures amount on the client side.
|
||||
// The lifetime of the cookie is the same as the configured MaxAge or one hour,
|
||||
// therefore a forbidden client can request for authentication again after expiration.
|
||||
//
|
||||
// You can always set custom logic on the Allow field as you have access to the current request instance.
|
||||
//
|
||||
// Defaults to "basicmaxtries".
|
||||
// The MaxTries should be set to greater than zero.
|
||||
MaxTriesCookie string
|
||||
// ErrorHandler handles the given request credentials failure.
|
||||
// E.g when the client tried to access a protected resource
|
||||
// with empty or invalid or expired credentials or
|
||||
// when Allow returned false and MaxTries consumed.
|
||||
//
|
||||
// Defaults to the DefaultErrorHandler, do not modify if you don't need to.
|
||||
ErrorHandler ErrorHandler
|
||||
// ErrorLogger if not nil then it logs any credentials failure errors
|
||||
// that are going to be sent to the client. Set it on debug development state.
|
||||
// Usage:
|
||||
// ErrorLogger = log.New(os.Stderr, "", log.LstdFlags)
|
||||
//
|
||||
// Defaults to nil.
|
||||
ErrorLogger *log.Logger
|
||||
// GC automatically clears old entries every x duration.
|
||||
// Note that, by old entries we mean expired credentials therefore
|
||||
// the `MaxAge` option should be already set,
|
||||
|
@ -85,22 +129,34 @@ type Options struct {
|
|||
// The standard context can be used for the internal ticker cancelation, it can be nil.
|
||||
//
|
||||
// Usage:
|
||||
// GC: basicauth.GC{Every: 2*time.Hour}
|
||||
// GC: basicauth.GC{Every: 2 * time.Hour}
|
||||
GC GC
|
||||
}
|
||||
|
||||
// GC holds the context and the tick duration to clear expired stored credentials.
|
||||
// See the Options.GC field.
|
||||
type GC struct {
|
||||
Context stdContext.Context
|
||||
Every time.Duration
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc2617
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication
|
||||
// BasicAuth implements the basic access authentication.
|
||||
// It is a method for an HTTP client (e.g. a web browser)
|
||||
// to provide a user name and password when making a request.
|
||||
// Basic authentication implementation is the simplest technique
|
||||
// for enforcing access controls to web resources because it does not require
|
||||
// cookies, session identifiers, or login pages; rather,
|
||||
// HTTP Basic authentication uses standard fields in the HTTP header.
|
||||
//
|
||||
// As the user ID and password are passed over the network as clear text
|
||||
// (it is base64 encoded, but base64 is a reversible encoding), the basic authentication scheme is not secure.
|
||||
// HTTPS/TLS should be used with basic authentication. Without these additional security enhancements,
|
||||
// basic authentication should not be used to protect sensitive or valuable information.
|
||||
// As the username and password are passed over the network as clear text
|
||||
// the basic authentication scheme is not secure on plain HTTP communication.
|
||||
// It is base64 encoded, but base64 is a reversible encoding.
|
||||
// HTTPS/TLS should be used with basic authentication.
|
||||
// Without these additional security enhancements,
|
||||
// basic authentication should NOT be used to protect sensitive or valuable information.
|
||||
//
|
||||
// Read https://tools.ietf.org/html/rfc2617 and
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication for details.
|
||||
type BasicAuth struct {
|
||||
opts Options
|
||||
// built based on proxy field
|
||||
|
@ -110,13 +166,35 @@ type BasicAuth struct {
|
|||
// built based on realm field.
|
||||
authenticateHeaderValue string
|
||||
|
||||
credentials map[string]*time.Time // key = username:password, value = expiration time (if MaxAge > 0).
|
||||
mu sync.RWMutex // protects the credentials as they can modified.
|
||||
// credentials stores the user expiration,
|
||||
// key = username:password, value = expiration time (if MaxAge > 0).
|
||||
credentials map[string]*time.Time // TODO: think of just a uint64 here (unix seconds).
|
||||
// protects the credentials concurrent access.
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New returns a new basic authentication middleware.
|
||||
// The result should be used to wrap an existing handler or the HTTP application's root router.
|
||||
//
|
||||
// Example Code:
|
||||
// opts := basicauth.Options{
|
||||
// Realm: basicauth.DefaultRealm,
|
||||
// ErrorHandler: basicauth.DefaultErrorHandler,
|
||||
// MaxAge: 2 * time.Hour,
|
||||
// GC: basicauth.GC{
|
||||
// Every: 3 * time.Hour,
|
||||
// },
|
||||
// Allow: basicauth.AllowUsers(users),
|
||||
// }
|
||||
// auth := basicauth.New(opts)
|
||||
// app.Use(auth)
|
||||
//
|
||||
// Access the user in the route handler with: ctx.User().GetRaw().(*myCustomType).
|
||||
//
|
||||
// Look the BasicAuth type docs for more information.
|
||||
func New(opts Options) context.Handler {
|
||||
var (
|
||||
askCode = 401
|
||||
askCode = http.StatusUnauthorized
|
||||
authorizationHeader = authorizationHeaderKey
|
||||
authenticateHeader = authenticateHeaderKey
|
||||
authenticateHeaderValue = "Basic"
|
||||
|
@ -131,7 +209,7 @@ func New(opts Options) context.Handler {
|
|||
}
|
||||
|
||||
if opts.Proxy {
|
||||
askCode = 407
|
||||
askCode = http.StatusProxyAuthRequired
|
||||
authenticateHeader = proxyAuthenticateHeaderKey
|
||||
authorizationHeader = proxyAuthorizationHeaderKey
|
||||
}
|
||||
|
@ -140,6 +218,10 @@ func New(opts Options) context.Handler {
|
|||
opts.MaxTriesCookie = DefaultMaxTriesCookie
|
||||
}
|
||||
|
||||
if opts.ErrorHandler == nil {
|
||||
opts.ErrorHandler = DefaultErrorHandler
|
||||
}
|
||||
|
||||
b := &BasicAuth{
|
||||
opts: opts,
|
||||
askCode: askCode,
|
||||
|
@ -156,10 +238,22 @@ func New(opts Options) context.Handler {
|
|||
return b.serveHTTP
|
||||
}
|
||||
|
||||
// - map[string]string form of: {username:password, ...} form.
|
||||
// - map[string]interface{} form of: []{"username": "...", "password": "...", "other_field": ...}, ...}.
|
||||
// - []T which T completes the User interface.
|
||||
// - []T which T contains at least Username and Password fields.
|
||||
// Default returns a new basic authentication middleware
|
||||
// based on pre-defined user list.
|
||||
// A user can hold any custom fields but the username and password
|
||||
// are required as they are compared against the user input
|
||||
// when access to protected resource is requested.
|
||||
// A user list can defined with one of the following values:
|
||||
// map[string]string form of: {username:password, ...}
|
||||
// map[string]interface{} form of: {"username": {"password": "...", "other_field": ...}, ...}
|
||||
// []T which T completes the User interface, where T is a struct value
|
||||
// []T which T contains at least Username and Password fields.
|
||||
//
|
||||
// Usage:
|
||||
// auth := Default(map[string]string{
|
||||
// "admin": "admin",
|
||||
// "john": "p@ss",
|
||||
// })
|
||||
func Default(users interface{}, userOpts ...UserAuthOption) context.Handler {
|
||||
opts := Options{
|
||||
Realm: DefaultRealm,
|
||||
|
@ -168,6 +262,11 @@ func Default(users interface{}, userOpts ...UserAuthOption) context.Handler {
|
|||
return New(opts)
|
||||
}
|
||||
|
||||
// Load same as Default but instead of a hard-coded user list it accepts
|
||||
// a filename to load the users from.
|
||||
//
|
||||
// Usage:
|
||||
// auth := Load("users.yml")
|
||||
func Load(jsonOrYamlFilename string, userOpts ...UserAuthOption) context.Handler {
|
||||
opts := Options{
|
||||
Realm: DefaultRealm,
|
||||
|
@ -176,71 +275,68 @@ func Load(jsonOrYamlFilename string, userOpts ...UserAuthOption) context.Handler
|
|||
return New(opts)
|
||||
}
|
||||
|
||||
// askForCredentials sends a response to the client which client should catch
|
||||
// and ask for username:password credentials.
|
||||
func (b *BasicAuth) askForCredentials(ctx *context.Context) {
|
||||
ctx.Header(b.authenticateHeader, b.authenticateHeaderValue)
|
||||
ctx.StopWithStatus(b.askCode)
|
||||
|
||||
if h := b.opts.OnAsk; h != nil {
|
||||
h(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// If a (proxy) server receives valid credentials that are inadequate to access a given resource,
|
||||
// the server should respond with the 403 Forbidden status code.
|
||||
// Unlike 401 Unauthorized or 407 Proxy Authentication Required, authentication is impossible for this user.
|
||||
func (b *BasicAuth) forbidden(ctx *context.Context) {
|
||||
ctx.StopWithStatus(403)
|
||||
|
||||
if h := b.opts.OnForbidden; h != nil {
|
||||
h(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BasicAuth) getCurrentTries(ctx *context.Context) (tries int) {
|
||||
sess := sessions.Get(ctx)
|
||||
if sess != nil {
|
||||
tries = sess.GetIntDefault(b.opts.MaxTriesCookie, 0)
|
||||
} else {
|
||||
if v := ctx.GetCookie(b.opts.MaxTriesCookie); v != "" {
|
||||
tries, _ = strconv.Atoi(v)
|
||||
}
|
||||
cookie := ctx.GetCookie(b.opts.MaxTriesCookie)
|
||||
if cookie != "" {
|
||||
tries, _ = strconv.Atoi(cookie)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (b *BasicAuth) setCurrentTries(ctx *context.Context, tries int) {
|
||||
sess := sessions.Get(ctx)
|
||||
if sess != nil {
|
||||
sess.Set(b.opts.MaxTriesCookie, tries)
|
||||
} else {
|
||||
maxAge := b.opts.MaxAge
|
||||
if maxAge == 0 {
|
||||
maxAge = context.SetCookieKVExpiration // 1 year.
|
||||
}
|
||||
ctx.SetCookieKV(b.opts.MaxTriesCookie, strconv.Itoa(tries), context.CookieExpires(maxAge))
|
||||
maxAge := b.opts.MaxAge
|
||||
if maxAge == 0 {
|
||||
maxAge = DefaultCookieMaxAge // 1 hour.
|
||||
}
|
||||
|
||||
c := &http.Cookie{
|
||||
Name: b.opts.MaxTriesCookie,
|
||||
Path: "/",
|
||||
Value: url.QueryEscape(strconv.Itoa(tries)),
|
||||
HttpOnly: true,
|
||||
Expires: time.Now().Add(maxAge),
|
||||
MaxAge: int(maxAge.Seconds()),
|
||||
}
|
||||
|
||||
ctx.SetCookie(c)
|
||||
}
|
||||
|
||||
func (b *BasicAuth) resetCurrentTries(ctx *context.Context) {
|
||||
sess := sessions.Get(ctx)
|
||||
if sess != nil {
|
||||
sess.Delete(b.opts.MaxTriesCookie)
|
||||
} else {
|
||||
ctx.RemoveCookie(b.opts.MaxTriesCookie)
|
||||
ctx.RemoveCookie(b.opts.MaxTriesCookie)
|
||||
}
|
||||
|
||||
func isHTTPS(r *http.Request) bool {
|
||||
return (strings.EqualFold(r.URL.Scheme, "https") || r.TLS != nil) && r.ProtoMajor == 2
|
||||
}
|
||||
|
||||
func (b *BasicAuth) handleError(ctx *context.Context, err error) {
|
||||
if b.opts.ErrorLogger != nil {
|
||||
b.opts.ErrorLogger.Println(err)
|
||||
}
|
||||
|
||||
// should not be nil as it's defaulted on New.
|
||||
b.opts.ErrorHandler(ctx, err)
|
||||
}
|
||||
|
||||
// serveHTTP is the main method of this middleware,
|
||||
// checks and verifies the auhorization header for basic authentication,
|
||||
// next handlers will only be executed when the client is allowed to continue.
|
||||
func (b *BasicAuth) serveHTTP(ctx *context.Context) {
|
||||
if b.opts.HTTPSOnly && !isHTTPS(ctx.Request()) {
|
||||
b.handleError(ctx, ErrHTTPVersion{})
|
||||
return
|
||||
}
|
||||
|
||||
header := ctx.GetHeader(b.authorizationHeader)
|
||||
fullUser, username, password, ok := decodeHeader(header)
|
||||
if !ok { // Header is malformed or missing.
|
||||
b.askForCredentials(ctx)
|
||||
if !ok { // Header is malformed or missing (e.g. browser cancel button on user prompt).
|
||||
b.handleError(ctx, ErrCredentialsMissing{
|
||||
Header: header,
|
||||
AuthenticateHeader: b.authenticateHeader,
|
||||
AuthenticateHeaderValue: b.authenticateHeaderValue,
|
||||
Code: b.askCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -259,12 +355,24 @@ func (b *BasicAuth) serveHTTP(ctx *context.Context) {
|
|||
tries++
|
||||
b.setCurrentTries(ctx, tries)
|
||||
if tries >= maxTries { // e.g. if MaxTries == 1 then it should be allowed only once, so we must send forbidden now.
|
||||
b.forbidden(ctx) // a user was forbidden, to reset its status should clear the Authorization header and cookie and request the resource again.
|
||||
b.handleError(ctx, ErrCredentialsForbidden{
|
||||
Username: username,
|
||||
Password: password,
|
||||
Tries: tries,
|
||||
Age: b.opts.MaxAge,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
b.askForCredentials(ctx)
|
||||
b.handleError(ctx, ErrCredentialsInvalid{
|
||||
Username: username,
|
||||
Password: password,
|
||||
CurrentTries: tries,
|
||||
AuthenticateHeader: b.authenticateHeader,
|
||||
AuthenticateHeaderValue: b.authenticateHeaderValue,
|
||||
Code: b.askCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -283,8 +391,15 @@ func (b *BasicAuth) serveHTTP(ctx *context.Context) {
|
|||
b.mu.Lock() // Delete the entry.
|
||||
delete(b.credentials, fullUser)
|
||||
b.mu.Unlock()
|
||||
|
||||
// Re-ask for new credentials.
|
||||
b.askForCredentials(ctx)
|
||||
b.handleError(ctx, ErrCredentialsExpired{
|
||||
Username: username,
|
||||
Password: password,
|
||||
AuthenticateHeader: b.authenticateHeader,
|
||||
AuthenticateHeaderValue: b.authenticateHeaderValue,
|
||||
Code: b.askCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -314,6 +429,10 @@ func (b *BasicAuth) serveHTTP(ctx *context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Store user instance and logout function.
|
||||
// Note that the end-developer has always have access
|
||||
// to the Request.BasicAuth, however, we support any user struct,
|
||||
// so we must store it on this request instance so it can be retrieved later on.
|
||||
ctx.SetUser(user)
|
||||
ctx.SetLogoutFunc(b.logout)
|
||||
|
||||
|
@ -336,12 +455,15 @@ func (b *BasicAuth) logout(ctx *context.Context) {
|
|||
|
||||
if !ok {
|
||||
// If the custom user does
|
||||
// not implement those two, then extract from the request header:
|
||||
// not implement the User interface, then extract from the request header (most common scenario):
|
||||
header := ctx.GetHeader(b.authorizationHeader)
|
||||
fullUser, username, password, ok = decodeHeader(header)
|
||||
}
|
||||
|
||||
if ok { // If it's authorized then try to lock and delete.
|
||||
ctx.SetUser(nil)
|
||||
ctx.SetLogoutFunc(nil)
|
||||
|
||||
if b.opts.Proxy {
|
||||
ctx.Request().Header.Del(proxyAuthorizationHeaderKey)
|
||||
}
|
||||
|
@ -374,16 +496,16 @@ func (b *BasicAuth) runGC(ctx stdContext.Context, every time.Duration) {
|
|||
}
|
||||
}
|
||||
|
||||
// gc removes all entries expired based on the max age or all entries (if max age is missing).
|
||||
// gc removes all entries expired based on the max age or all entries (if max age is missing),
|
||||
// note that this does not mean that the server will send 401/407 to the next request,
|
||||
// when the request header credentials are still valid (Allow passed).
|
||||
func (b *BasicAuth) gc() int {
|
||||
now := time.Now()
|
||||
var markedForDeletion []string
|
||||
|
||||
b.mu.RLock()
|
||||
for fullUser, expiresAt := range b.credentials {
|
||||
if expiresAt == nil {
|
||||
markedForDeletion = append(markedForDeletion, fullUser)
|
||||
} else if expiresAt.Before(now) {
|
||||
if expiresAt == nil || expiresAt.Before(now) {
|
||||
markedForDeletion = append(markedForDeletion, fullUser)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
"github.com/kataras/iris/v12"
|
||||
"github.com/kataras/iris/v12/httptest"
|
||||
basicauth "github.com/kataras/iris/v12/middleware/basicauth"
|
||||
"github.com/kataras/iris/v12/middleware/basicauth"
|
||||
)
|
||||
|
||||
func TestBasicAuthUseRouter(t *testing.T) {
|
||||
|
|
108
middleware/basicauth/error.go
Normal file
108
middleware/basicauth/error.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -8,17 +8,50 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/kataras/iris/v12/context"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ReadFile can be used to customize the way the
|
||||
// AllowUsersFile function is loading the filename from.
|
||||
// Example of usage: embedded users.yml file.
|
||||
// Defaults to the `ioutil.ReadFile` which reads the file from the physical disk.
|
||||
var ReadFile = ioutil.ReadFile
|
||||
|
||||
// User is a partial part of the iris.User interface.
|
||||
// It's used to declare a static slice of registered User for authentication.
|
||||
type User interface {
|
||||
context.UserGetUsername
|
||||
context.UserGetPassword
|
||||
}
|
||||
|
||||
// UserAuthOptions holds optional user authentication options
|
||||
// that can be given to the builtin Default and Load (and AllowUsers, AllowUsersFile) functions.
|
||||
type UserAuthOptions struct {
|
||||
// Defaults to plain check, can be modified for encrypted passwords, see `BCRYPT`.
|
||||
// Defaults to plain check, can be modified for encrypted passwords,
|
||||
// see the BCRYPT optional function.
|
||||
ComparePassword func(stored, userPassword string) bool
|
||||
}
|
||||
|
||||
// UserAuthOption is the option function type
|
||||
// for the Default and Load (and AllowUsers, AllowUsersFile) functions.
|
||||
//
|
||||
// See BCRYPT for an implementation.
|
||||
type UserAuthOption func(*UserAuthOptions)
|
||||
|
||||
// BCRYPT it is a UserAuthOption, it compares a bcrypt hashed password with its user input.
|
||||
// Reports true on success and false on failure.
|
||||
//
|
||||
// Useful when the users passwords are encrypted
|
||||
// using the Provos and Mazières's bcrypt adaptive hashing algorithm.
|
||||
// See https://www.usenix.org/legacy/event/usenix99/provos/provos.pdf.
|
||||
//
|
||||
// Usage:
|
||||
// Default(..., BCRYPT) OR
|
||||
// Load(..., BCRYPT) OR
|
||||
// Options.Allow = AllowUsers(..., BCRYPT) OR
|
||||
// OPtions.Allow = AllowUsersFile(..., BCRYPT)
|
||||
func BCRYPT(opts *UserAuthOptions) {
|
||||
opts.ComparePassword = func(stored, userPassword string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(stored), []byte(userPassword))
|
||||
|
@ -40,16 +73,15 @@ func toUserAuthOptions(opts []UserAuthOption) (options UserAuthOptions) {
|
|||
return options
|
||||
}
|
||||
|
||||
type User interface {
|
||||
context.UserGetUsername
|
||||
context.UserGetPassword
|
||||
}
|
||||
|
||||
// Users
|
||||
// - map[string]string form of: {username:password, ...} form.
|
||||
// - map[string]interface{} form of: []{"username": "...", "password": "...", "other_field": ...}, ...}.
|
||||
// - []T which T completes the User interface.
|
||||
// - []T which T contains at least Username and Password fields.
|
||||
// AllowUsers is an AuthFunc which authenticates user input based on a (static) user list.
|
||||
// The "users" input parameter can be one of the following forms:
|
||||
// map[string]string e.g. {username: password, username: password...}.
|
||||
// []map[string]interface{} e.g. []{"username": "...", "password": "...", "other_field": ...}, ...}.
|
||||
// []T which T completes the User interface.
|
||||
// []T which T contains at least Username and Password fields.
|
||||
//
|
||||
// Usage:
|
||||
// New(Options{Allow: AllowUsers(..., [BCRYPT])})
|
||||
func AllowUsers(users interface{}, opts ...UserAuthOption) AuthFunc {
|
||||
// create a local user structure to be used in the map copy,
|
||||
// takes longer to initialize but faster to serve.
|
||||
|
@ -119,12 +151,24 @@ func userMap(usernamePassword map[string]string, opts ...UserAuthOption) AuthFun
|
|||
}
|
||||
}
|
||||
|
||||
// AllowUsersFile is an AuthFunc which authenticates user input based on a (static) user list
|
||||
// loaded from a file on initialization.
|
||||
//
|
||||
// Example Code:
|
||||
// New(Options{Allow: AllowUsersFile("users.yml", BCRYPT)})
|
||||
// The users.yml file looks like the following:
|
||||
// - username: kataras
|
||||
// password: kataras_pass
|
||||
// age: 27
|
||||
// role: admin
|
||||
// - username: makis
|
||||
// password: makis_password
|
||||
// ...
|
||||
func AllowUsersFile(jsonOrYamlFilename string, opts ...UserAuthOption) AuthFunc {
|
||||
var (
|
||||
usernamePassword map[string]string
|
||||
// no need to support too much forms, this would be for:
|
||||
// "$username": { "password": "$pass", "other_field": ...}
|
||||
// users map[string]map[string]interface{}
|
||||
userList []map[string]interface{}
|
||||
)
|
||||
|
||||
|
@ -152,7 +196,7 @@ func AllowUsersFile(jsonOrYamlFilename string, opts ...UserAuthOption) AuthFunc
|
|||
}
|
||||
|
||||
func decodeFile(src string, dest ...interface{}) error {
|
||||
data, err := ioutil.ReadFile(src)
|
||||
data, err := ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
|
@ -20,7 +20,7 @@ type IUserRepository interface {
|
|||
// Test a custom implementation of AuthFunc with a user repository.
|
||||
// This is a usage example of custom AuthFunc implementation.
|
||||
func UserRepository(repo IUserRepository, newUserPtr func() interface{}) AuthFunc {
|
||||
return func(ctx *context.Context, username, password string) (interface{}, bool) {
|
||||
return func(_ *context.Context, username, password string) (interface{}, bool) {
|
||||
dest := newUserPtr()
|
||||
err := repo.GetByUsernameAndPassword(dest, username, password)
|
||||
if err == nil {
|
||||
|
@ -37,7 +37,7 @@ type testUser struct {
|
|||
email string // custom field.
|
||||
}
|
||||
|
||||
// GetUsername & Getpassword complete the User interface (optional but useful on Context.User()).
|
||||
// GetUsername & Getpassword complete the User interface.
|
||||
func (u *testUser) GetUsername() string {
|
||||
return u.username
|
||||
}
|
||||
|
@ -224,6 +224,7 @@ func TestAllowUsersFile(t *testing.T) {
|
|||
if tt.ok {
|
||||
// store the hashed password.
|
||||
tt.password = mustGeneratePassword(t, tt.inputPassword)
|
||||
|
||||
// store and write the username and hashed password.
|
||||
tt.user["username"] = tt.username
|
||||
tt.user["password"] = tt.password
|
Loading…
Reference in New Issue
Block a user