mirror of
https://github.com/kataras/iris.git
synced 2025-01-23 02:31:04 +01:00
add a jwt tutorial + go client
This commit is contained in:
parent
ed38047385
commit
579c3878f0
|
@ -199,9 +199,11 @@
|
|||
* [Basic Authentication](auth/basicauth/main.go)
|
||||
* [CORS](auth/cors)
|
||||
* JSON Web Tokens
|
||||
* [Overview](auth/jwt/overview/main.go)
|
||||
* [Basic](auth/jwt/basic/main.go)
|
||||
* [Middleware](auth/jwt/midleware/main.go)
|
||||
* [Blocklist](auth/jwt/blocklist/main.go)
|
||||
* [Refresh Token](auth/jwt/refresh-token/main.go)
|
||||
* [Tutorial](auth/jwt/tutorial)
|
||||
* [JWT (community edition)](https://github.com/iris-contrib/middleware/tree/v12/jwt/_example/main.go)
|
||||
* [OAUth2](auth/goth/main.go)
|
||||
* [Manage Permissions](auth/permissions/main.go)
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
package main
|
||||
|
||||
import "github.com/kataras/iris/v12"
|
||||
|
||||
func loginView(ctx iris.Context) {
|
||||
|
||||
}
|
||||
|
||||
func login(ctx iris.Context) {
|
||||
|
||||
}
|
||||
|
||||
func logout(ctx iris.Context) {
|
||||
ctx.Logout()
|
||||
|
||||
ctx.Redirect("/", iris.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func createTodo(ctx iris.Context) {
|
||||
|
||||
}
|
||||
|
||||
func getTodo(ctx iris.Context) {
|
||||
|
||||
}
|
142
_examples/auth/jwt/tutorial/api/auth.go
Normal file
142
_examples/auth/jwt/tutorial/api/auth.go
Normal file
|
@ -0,0 +1,142 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"myapp/domain/model"
|
||||
"myapp/domain/repository"
|
||||
"myapp/util"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
"github.com/kataras/iris/v12/middleware/jwt"
|
||||
)
|
||||
|
||||
const defaultSecretKey = "sercrethatmaycontainch@r$32chars"
|
||||
|
||||
func getSecretKey() string {
|
||||
secret := os.Getenv(util.AppName + "_SECRET")
|
||||
if secret == "" {
|
||||
return defaultSecretKey
|
||||
}
|
||||
|
||||
return secret
|
||||
}
|
||||
|
||||
// UserClaims represents the user token claims.
|
||||
type UserClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Roles []model.Role `json:"roles"`
|
||||
}
|
||||
|
||||
// Validate implements the custom struct claims validator,
|
||||
// this is totally optionally and maybe unnecessary but good to know how.
|
||||
func (u *UserClaims) Validate() error {
|
||||
if u.UserID == "" {
|
||||
return fmt.Errorf("%w: %s", jwt.ErrMissingKey, "user_id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify allows only authorized clients.
|
||||
func Verify() iris.Handler {
|
||||
secret := getSecretKey()
|
||||
|
||||
verifier := jwt.NewVerifier(jwt.HS256, []byte(secret), jwt.Expected{Issuer: util.AppName})
|
||||
verifier.Extractors = []jwt.TokenExtractor{jwt.FromHeader} // extract token only from Authorization: Bearer $token
|
||||
return verifier.Verify(func() interface{} {
|
||||
return new(UserClaims)
|
||||
})
|
||||
}
|
||||
|
||||
// AllowAdmin allows only authorized clients with "admin" access role.
|
||||
// Should be registered after Verify.
|
||||
func AllowAdmin(ctx iris.Context) {
|
||||
if !IsAdmin(ctx) {
|
||||
ctx.StopWithText(iris.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
// SignIn accepts the user form data and returns a token to authorize a client.
|
||||
func SignIn(repo repository.UserRepository) iris.Handler {
|
||||
secret := getSecretKey()
|
||||
signer := jwt.NewSigner(jwt.HS256, []byte(secret), 15*time.Minute)
|
||||
|
||||
return func(ctx iris.Context) {
|
||||
/*
|
||||
type LoginForm struct {
|
||||
Username string `form:"username"`
|
||||
Password string `form:"password"`
|
||||
}
|
||||
and ctx.ReadForm OR use the ctx.FormValue(s) method.
|
||||
*/
|
||||
|
||||
var (
|
||||
username = ctx.FormValue("username")
|
||||
password = ctx.FormValue("password")
|
||||
)
|
||||
|
||||
user, ok := repo.GetByUsernameAndPassword(username, password)
|
||||
if !ok {
|
||||
ctx.StopWithText(iris.StatusBadRequest, "wrong username or password")
|
||||
return
|
||||
}
|
||||
|
||||
claims := UserClaims{
|
||||
UserID: user.ID,
|
||||
Roles: user.Roles,
|
||||
}
|
||||
|
||||
// Optionally, generate a JWT ID.
|
||||
jti, err := util.GenerateUUID()
|
||||
if err != nil {
|
||||
ctx.StopWithError(iris.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := signer.Sign(claims, jwt.Claims{
|
||||
ID: jti,
|
||||
Issuer: util.AppName,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.StopWithError(iris.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Write(token)
|
||||
}
|
||||
}
|
||||
|
||||
// SignOut invalidates a user from server-side using the jwt Blocklist.
|
||||
// It's not used as we don't attach a blocklist, the user can be logged out from our client
|
||||
// and we don't use access token so we don't actually need this in this example.
|
||||
func SignOut(ctx iris.Context) {
|
||||
ctx.Logout()
|
||||
}
|
||||
|
||||
// GetClaims returns the current authorized client claims.
|
||||
func GetClaims(ctx iris.Context) *UserClaims {
|
||||
claims := jwt.Get(ctx).(*UserClaims)
|
||||
return claims
|
||||
}
|
||||
|
||||
// GetUserID returns the current authorized client's user id extracted from claims.
|
||||
func GetUserID(ctx iris.Context) string {
|
||||
return GetClaims(ctx).UserID
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the current client has admin access.
|
||||
func IsAdmin(ctx iris.Context) bool {
|
||||
for _, role := range GetClaims(ctx).Roles {
|
||||
if role == model.Admin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
119
_examples/auth/jwt/tutorial/api/todo.go
Normal file
119
_examples/auth/jwt/tutorial/api/todo.go
Normal file
|
@ -0,0 +1,119 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"myapp/domain/repository"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
)
|
||||
|
||||
// TodoRequest represents a Todo HTTP request.
|
||||
type TodoRequest struct {
|
||||
Title string `json:"title" form:"title" url:"title"`
|
||||
Body string `json:"body" form:"body" url:"body"`
|
||||
}
|
||||
|
||||
// CreateTodo handles the creation of a Todo entry.
|
||||
func CreateTodo(repo repository.TodoRepository) iris.Handler {
|
||||
return func(ctx iris.Context) {
|
||||
var req TodoRequest
|
||||
err := ctx.ReadBody(&req) // will bind the "req" to a JSON, form or url query request data.
|
||||
if err != nil {
|
||||
ctx.StopWithError(iris.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := GetUserID(ctx)
|
||||
todo, err := repo.Create(userID, req.Title, req.Body)
|
||||
if err != nil {
|
||||
ctx.StopWithError(iris.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.StatusCode(iris.StatusCreated)
|
||||
ctx.JSON(todo)
|
||||
}
|
||||
}
|
||||
|
||||
// GetTodo lists all users todos.
|
||||
// Parameter: {id}.
|
||||
func GetTodo(repo repository.TodoRepository) iris.Handler {
|
||||
return func(ctx iris.Context) {
|
||||
id := ctx.Params().Get("id")
|
||||
userID := GetUserID(ctx)
|
||||
|
||||
todo, err := repo.GetByID(id)
|
||||
if err != nil {
|
||||
code := iris.StatusInternalServerError
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
code = iris.StatusNotFound
|
||||
}
|
||||
|
||||
ctx.StopWithError(code, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !IsAdmin(ctx) { // admin can access any user's todos.
|
||||
if todo.UserID != userID {
|
||||
ctx.StopWithStatus(iris.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(todo)
|
||||
}
|
||||
}
|
||||
|
||||
// ListTodos lists todos of the current user.
|
||||
func ListTodos(repo repository.TodoRepository) iris.Handler {
|
||||
return func(ctx iris.Context) {
|
||||
userID := GetUserID(ctx)
|
||||
todos, err := repo.GetAllByUser(userID)
|
||||
if err != nil {
|
||||
ctx.StopWithError(iris.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// if len(todos) == 0 {
|
||||
// ctx.StopWithError(iris.StatusNotFound, fmt.Errorf("no entries found"))
|
||||
// return
|
||||
// }
|
||||
// Or let the client decide what to do on empty list.
|
||||
ctx.JSON(todos)
|
||||
}
|
||||
}
|
||||
|
||||
// ListAllTodos lists all users todos.
|
||||
// Access: admin.
|
||||
// Middleware: AllowAdmin.
|
||||
func ListAllTodos(repo repository.TodoRepository) iris.Handler {
|
||||
return func(ctx iris.Context) {
|
||||
todos, err := repo.GetAll()
|
||||
if err != nil {
|
||||
ctx.StopWithError(iris.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(todos)
|
||||
}
|
||||
}
|
||||
|
||||
/* Leave as exercise: use filtering instead...
|
||||
|
||||
// ListTodosByUser lists all todos by a specific user.
|
||||
// Access: admin.
|
||||
// Middleware: AllowAdmin.
|
||||
// Parameter: {id}.
|
||||
func ListTodosByUser(repo repository.TodoRepository) iris.Handler {
|
||||
return func(ctx iris.Context) {
|
||||
userID := ctx.Params().Get("id")
|
||||
todos, err := repo.GetAllByUser(userID)
|
||||
if err != nil {
|
||||
ctx.StopWithError(iris.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(todos)
|
||||
}
|
||||
}
|
||||
*/
|
9
_examples/auth/jwt/tutorial/domain/model/role.go
Normal file
9
_examples/auth/jwt/tutorial/domain/model/role.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package model
|
||||
|
||||
// Role represents a role.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// Admin represents the Admin access role.
|
||||
Admin Role = "admin"
|
||||
)
|
10
_examples/auth/jwt/tutorial/domain/model/todo.go
Normal file
10
_examples/auth/jwt/tutorial/domain/model/todo.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
package model
|
||||
|
||||
// Todo represents the Todo model.
|
||||
type Todo struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt int64 `json:"created_at"` // unix seconds.
|
||||
}
|
9
_examples/auth/jwt/tutorial/domain/model/user.go
Normal file
9
_examples/auth/jwt/tutorial/domain/model/user.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package model
|
||||
|
||||
// User represents our User model.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
HashedPassword []byte `json:"-"`
|
||||
Roles []Role `json:"roles"`
|
||||
}
|
45
_examples/auth/jwt/tutorial/domain/repository/samples.go
Normal file
45
_examples/auth/jwt/tutorial/domain/repository/samples.go
Normal file
|
@ -0,0 +1,45 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"myapp/domain/model"
|
||||
)
|
||||
|
||||
// GenerateSamples generates data samples.
|
||||
func GenerateSamples(userRepo UserRepository, todoRepo TodoRepository) error {
|
||||
// Create users.
|
||||
for _, username := range []string{"vasiliki", "george", "kwstas"} {
|
||||
// My grandmother.
|
||||
// My young brother.
|
||||
// My youngest brother.
|
||||
password := fmt.Sprintf("%s_pass", username)
|
||||
if _, err := userRepo.Create(username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create a user with admin role.
|
||||
if _, err := userRepo.Create("admin", "admin", model.Admin); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create two todos per user.
|
||||
users, err := userRepo.GetAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, u := range users {
|
||||
for j := 0; j < 2; j++ {
|
||||
title := fmt.Sprintf("%s todo %d:%d title", u.Username, i, j)
|
||||
body := fmt.Sprintf("%s todo %d:%d body", u.Username, i, j)
|
||||
_, err := todoRepo.Create(u.ID, title, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"myapp/domain/model"
|
||||
"myapp/util"
|
||||
)
|
||||
|
||||
// ErrNotFound indicates that an entry was not found.
|
||||
// Usage: errors.Is(err, ErrNotFound)
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// TodoRepository is responsible for Todo CRUD operations,
|
||||
// however, for the sake of the example we only implement the Create and Read ones.
|
||||
type TodoRepository interface {
|
||||
Create(userID, title, body string) (model.Todo, error)
|
||||
GetByID(id string) (model.Todo, error)
|
||||
GetAll() ([]model.Todo, error)
|
||||
GetAllByUser(userID string) ([]model.Todo, error)
|
||||
}
|
||||
|
||||
var (
|
||||
_ TodoRepository = (*memoryTodoRepository)(nil)
|
||||
)
|
||||
|
||||
type memoryTodoRepository struct {
|
||||
todos []model.Todo // map[string]model.Todo
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemoryTodoRepository returns the default in-memory todo repository.
|
||||
func NewMemoryTodoRepository() TodoRepository {
|
||||
r := new(memoryTodoRepository)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *memoryTodoRepository) Create(userID, title, body string) (model.Todo, error) {
|
||||
id, err := util.GenerateUUID()
|
||||
if err != nil {
|
||||
return model.Todo{}, err
|
||||
}
|
||||
|
||||
todo := model.Todo{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Body: body,
|
||||
CreatedAt: util.Now().Unix(),
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.todos = append(r.todos, todo)
|
||||
r.mu.Unlock()
|
||||
|
||||
return todo, nil
|
||||
}
|
||||
|
||||
func (r *memoryTodoRepository) GetByID(id string) (model.Todo, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, todo := range r.todos {
|
||||
if todo.ID == id {
|
||||
return todo, nil
|
||||
}
|
||||
}
|
||||
|
||||
return model.Todo{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (r *memoryTodoRepository) GetAll() ([]model.Todo, error) {
|
||||
r.mu.RLock()
|
||||
tmp := make([]model.Todo, len(r.todos))
|
||||
copy(tmp, r.todos)
|
||||
r.mu.RUnlock()
|
||||
return tmp, nil
|
||||
}
|
||||
|
||||
func (r *memoryTodoRepository) GetAllByUser(userID string) ([]model.Todo, error) {
|
||||
// initialize a slice, so we don't have "null" at empty response.
|
||||
todos := make([]model.Todo, 0)
|
||||
|
||||
r.mu.RLock()
|
||||
for _, todo := range r.todos {
|
||||
if todo.UserID == userID {
|
||||
todos = append(todos, todo)
|
||||
}
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
return todos, nil
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"myapp/domain/model"
|
||||
"myapp/util"
|
||||
)
|
||||
|
||||
// UserRepository is responsible for User CRUD operations,
|
||||
// however, for the sake of the example we only implement the Read one.
|
||||
type UserRepository interface {
|
||||
Create(username, password string, roles ...model.Role) (model.User, error)
|
||||
// GetByUsernameAndPassword should return a User based on the given input.
|
||||
GetByUsernameAndPassword(username, password string) (model.User, bool)
|
||||
GetAll() ([]model.User, error)
|
||||
}
|
||||
|
||||
var (
|
||||
_ UserRepository = (*memoryUserRepository)(nil)
|
||||
)
|
||||
|
||||
type memoryUserRepository struct {
|
||||
// Users represents a user database.
|
||||
// For the sake of the tutorial we use a simple slice of users.
|
||||
users []model.User
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemoryUserRepository returns the default in-memory user repository.
|
||||
func NewMemoryUserRepository() UserRepository {
|
||||
r := new(memoryUserRepository)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *memoryUserRepository) Create(username, password string, roles ...model.Role) (model.User, error) {
|
||||
id, err := util.GenerateUUID()
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
hashedPassword, err := util.GeneratePassword(password)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
HashedPassword: hashedPassword,
|
||||
Roles: roles,
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.users = append(r.users, user)
|
||||
r.mu.Unlock()
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByUsernameAndPassword returns a user from the storage based on the given "username" and "password".
|
||||
func (r *memoryUserRepository) GetByUsernameAndPassword(username, password string) (model.User, bool) {
|
||||
for _, u := range r.users { // our example uses a static slice.
|
||||
if u.Username == username {
|
||||
// we compare the user input and the stored hashed password.
|
||||
ok := util.ValidatePassword(password, u.HashedPassword)
|
||||
if ok {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return model.User{}, false
|
||||
}
|
||||
|
||||
func (r *memoryUserRepository) GetAll() ([]model.User, error) {
|
||||
r.mu.RLock()
|
||||
tmp := make([]model.User, len(r.users))
|
||||
copy(tmp, r.users)
|
||||
r.mu.RUnlock()
|
||||
return tmp, nil
|
||||
}
|
12
_examples/auth/jwt/tutorial/go-client/README.md
Normal file
12
_examples/auth/jwt/tutorial/go-client/README.md
Normal file
|
@ -0,0 +1,12 @@
|
|||
# Go Client
|
||||
|
||||
```sh
|
||||
$ go run .
|
||||
```
|
||||
|
||||
```sh
|
||||
2020/11/04 21:08:40 Access Token:
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYTAwYzI3ZDEtYjVhYS00NjU0LWFmMTYtYjExNzNkZTY1NjI5Iiwicm9sZXMiOlsiYWRtaW4iXSwiaWF0IjoxNjA0NTE2OTIwLCJleHAiOjE2MDQ1MTc4MjAsImp0aSI6IjYzNmVmMDc0LTE2MzktNGJhZi1hNGNiLTQ4ZDM4NGMxMzliYSIsImlzcyI6Im15YXBwIn0.T9B0zG0AHShO5JfQgrMQBlToH33KHgp8nLMPFpN6QmM"
|
||||
2020/11/04 21:08:40 Todo Created:
|
||||
model.Todo{ID:"cfa38d7a-c556-4301-ae1f-fb90f705071c", UserID:"a00c27d1-b5aa-4654-af16-b1173de65629", Title:"test todo title", Body:"test todo body contents", CreatedAt:1604516920}
|
||||
```
|
109
_examples/auth/jwt/tutorial/go-client/client.go
Normal file
109
_examples/auth/jwt/tutorial/go-client/client.go
Normal file
|
@ -0,0 +1,109 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Client is the default http client instance used by the following methods.
|
||||
var Client = http.DefaultClient
|
||||
|
||||
// RequestOption is a function which can be used to modify
|
||||
// a request instance before Do.
|
||||
type RequestOption func(*http.Request) error
|
||||
|
||||
// WithAccessToken sets the given "token" to the authorization request header.
|
||||
func WithAccessToken(token []byte) RequestOption {
|
||||
bearer := "Bearer " + string(token)
|
||||
return func(req *http.Request) error {
|
||||
req.Header.Add("Authorization", bearer)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithContentType sets the content-type request header.
|
||||
func WithContentType(cType string) RequestOption {
|
||||
return func(req *http.Request) error {
|
||||
req.Header.Set("Content-Type", cType)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithContentLength sets the content-length request header.
|
||||
func WithContentLength(length int) RequestOption {
|
||||
return func(req *http.Request) error {
|
||||
req.Header.Set("Content-Length", strconv.Itoa(length))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Do fires a request to the server.
|
||||
func Do(method, url string, body io.Reader, opts ...RequestOption) (*http.Response, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if err = opt(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return Client.Do(req)
|
||||
}
|
||||
|
||||
// JSON fires a request with "v" as client json data.
|
||||
func JSON(method, url string, v interface{}, opts ...RequestOption) (*http.Response, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
err := json.NewEncoder(buf).Encode(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts = append(opts, WithContentType("application/json; charset=utf-8"))
|
||||
return Do(method, url, buf, opts...)
|
||||
}
|
||||
|
||||
// Form fires a request with "formData" as client form data.
|
||||
func Form(method, url string, formData url.Values, opts ...RequestOption) (*http.Response, error) {
|
||||
encoded := formData.Encode()
|
||||
body := strings.NewReader(encoded)
|
||||
|
||||
opts = append([]RequestOption{
|
||||
WithContentType("application/x-www-form-urlencoded"),
|
||||
WithContentLength(len(encoded)),
|
||||
}, opts...)
|
||||
|
||||
return Do(method, url, body, opts...)
|
||||
}
|
||||
|
||||
// BindResponse binds a response body to the "dest" pointer and closes the body.
|
||||
func BindResponse(resp *http.Response, dest interface{}) error {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if idx := strings.IndexRune(contentType, ';'); idx > 0 {
|
||||
contentType = contentType[0:idx]
|
||||
}
|
||||
|
||||
switch contentType {
|
||||
case "application/json":
|
||||
defer resp.Body.Close()
|
||||
return json.NewDecoder(resp.Body).Decode(dest)
|
||||
default:
|
||||
return fmt.Errorf("unsupported content type: %s", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
// RawResponse simply returns the raw response body.
|
||||
func RawResponse(resp *http.Response) ([]byte, error) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
return ioutil.ReadAll(resp.Body)
|
||||
}
|
69
_examples/auth/jwt/tutorial/go-client/main.go
Normal file
69
_examples/auth/jwt/tutorial/go-client/main.go
Normal file
|
@ -0,0 +1,69 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"myapp/api"
|
||||
"myapp/domain/model"
|
||||
)
|
||||
|
||||
const base = "http://localhost:8080"
|
||||
|
||||
func main() {
|
||||
accessToken, err := authenticate("admin", "admin")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Printf("Access Token:\n%q", accessToken)
|
||||
|
||||
todo, err := createTodo(accessToken, "test todo title", "test todo body contents")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Printf("Todo Created:\n%#+v", todo)
|
||||
}
|
||||
|
||||
func authenticate(username, password string) ([]byte, error) {
|
||||
endpoint := base + "/signin"
|
||||
|
||||
data := make(url.Values)
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
|
||||
resp, err := Form(http.MethodPost, endpoint, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessToken, err := RawResponse(resp)
|
||||
return accessToken, err
|
||||
}
|
||||
|
||||
func createTodo(accessToken []byte, title, body string) (model.Todo, error) {
|
||||
var todo model.Todo
|
||||
|
||||
endpoint := base + "/todos"
|
||||
|
||||
req := api.TodoRequest{
|
||||
Title: title,
|
||||
Body: body,
|
||||
}
|
||||
|
||||
resp, err := JSON(http.MethodPost, endpoint, req, WithAccessToken(accessToken))
|
||||
if err != nil {
|
||||
return todo, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
rawData, _ := RawResponse(resp)
|
||||
return todo, fmt.Errorf("failed to create a todo: %s", string(rawData))
|
||||
}
|
||||
|
||||
err = BindResponse(resp, &todo)
|
||||
return todo, err
|
||||
}
|
|
@ -3,8 +3,9 @@ module myapp
|
|||
go 1.15
|
||||
|
||||
require (
|
||||
github.com/kataras/iris/v12 v12.2.0-alpha.0.20201031040657-23d4c411cadb
|
||||
github.com/google/uuid v1.1.2
|
||||
github.com/kataras/iris/v12 v12.2.0-alpha.0.20201031040657-23d4c411cadb
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
|
||||
)
|
||||
|
||||
replace github.com/kataras/iris/v12 => ../../../../
|
||||
|
|
|
@ -1,89 +1,40 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
"myapp/api"
|
||||
"myapp/domain/repository"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
"github.com/kataras/iris/v12/middleware/jwt"
|
||||
"github.com/kataras/iris/v12/middleware/jwt/blocklist/redis"
|
||||
|
||||
// Optionally to set token identifier.
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
signatureSharedKey = []byte("sercrethatmaycontainch@r32length")
|
||||
|
||||
signer = jwt.NewSigner(jwt.HS256, signatureSharedKey, 15*time.Minute)
|
||||
verifier = jwt.NewVerifier(jwt.HS256, signatureSharedKey)
|
||||
userRepository = repository.NewMemoryUserRepository()
|
||||
todoRepository = repository.NewMemoryTodoRepository()
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := repository.GenerateSamples(userRepository, todoRepository); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
app := iris.New()
|
||||
|
||||
blocklist := redis.NewBlocklist()
|
||||
verifier.Blocklist = blocklist
|
||||
verifyMiddleware := verifier.Verify(func() interface{} {
|
||||
return new(userClaims)
|
||||
})
|
||||
app.Post("/signin", api.SignIn(userRepository))
|
||||
|
||||
app.Get("/", loginView)
|
||||
verify := api.Verify()
|
||||
|
||||
api := app.Party("/api")
|
||||
{
|
||||
api.Post("/login", login)
|
||||
api.Post("/logout", verifyMiddleware, logout)
|
||||
todosAPI := app.Party("/todos", verify)
|
||||
todosAPI.Post("/", api.CreateTodo(todoRepository))
|
||||
todosAPI.Get("/", api.ListTodos(todoRepository))
|
||||
todosAPI.Get("/{id}", api.GetTodo(todoRepository))
|
||||
|
||||
todoAPI := api.Party("/todos", verifyMiddleware)
|
||||
{
|
||||
todoAPI.Post("/", createTodo)
|
||||
todoAPI.Get("/", listTodos)
|
||||
todoAPI.Get("/{id:uint64}", getTodo)
|
||||
}
|
||||
}
|
||||
adminAPI := app.Party("/admin", verify, api.AllowAdmin)
|
||||
adminAPI.Get("/todos", api.ListAllTodos(todoRepository))
|
||||
|
||||
protectedAPI := app.Party("/protected", verifyMiddleware)
|
||||
protectedAPI.Get("/", protected)
|
||||
protectedAPI.Get("/logout", logout)
|
||||
|
||||
// GET http://localhost:8080
|
||||
// POST http://localhost:8080/api/login
|
||||
// POST http://localhost:8080/api/logout
|
||||
// POST http://localhost:8080/api/todos
|
||||
// GET http://localhost:8080/api/todos
|
||||
// GET http://localhost:8080/api/todos/{id}
|
||||
// POST http://localhost:8080/signin (Form: username, password)
|
||||
// GET http://localhost:8080/todos
|
||||
// GET http://localhost:8080/todos/{id}
|
||||
// POST http://localhost:8080/todos (JSON, Form or URL: title, body)
|
||||
// GET http://localhost:8080/admin/todos
|
||||
app.Listen(":8080")
|
||||
}
|
||||
|
||||
func authenticate(ctx iris.Context) {
|
||||
claims := userClaims{
|
||||
Username: "kataras",
|
||||
}
|
||||
|
||||
// Generate JWT ID.
|
||||
random, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
ctx.StopWithError(iris.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
id := random.String()
|
||||
|
||||
// Set the ID with the jwt.ID.
|
||||
token, err := signer.Sign(claims, jwt.ID(id))
|
||||
|
||||
if err != nil {
|
||||
ctx.StopWithError(iris.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Write(token)
|
||||
}
|
||||
|
||||
func protected(ctx iris.Context) {
|
||||
claims := jwt.Get(ctx).(*userClaims)
|
||||
|
||||
// To the standard claims, e.g. the generated ID:
|
||||
// jwt.GetVerifiedToken(ctx).StandardClaims.ID
|
||||
|
||||
ctx.WriteString(claims.Username)
|
||||
}
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
package main
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
func init() {
|
||||
generateSampleUsers()
|
||||
}
|
||||
|
||||
// User represents our User model.
|
||||
type User struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
HashedPassword []byte `json:"-"`
|
||||
}
|
||||
|
||||
// Users represents a user database.
|
||||
// For the sake of the tutorial we use a simple slice of users.
|
||||
var Users []User
|
||||
|
||||
func generateSampleUsers() {
|
||||
Users = []User{
|
||||
{ID: 1, Username: "vasiliki", HashedPassword: mustGeneratePassword("vasiliki_pass")}, // my grandmother.
|
||||
{ID: 2, Username: "kataras", HashedPassword: mustGeneratePassword("kataras_pass")}, // me.
|
||||
{ID: 3, Username: "george", HashedPassword: mustGeneratePassword("george_pass")}, // my young brother.
|
||||
{ID: 4, Username: "kwstas", HashedPassword: mustGeneratePassword("kwstas_pass")}, // my youngest brother.
|
||||
}
|
||||
}
|
||||
|
||||
func fetchUser(username, password string) (User, bool) {
|
||||
for _, u := range Users { // our example uses a static slice.
|
||||
if u.Username == username {
|
||||
// we compare the user input and the stored hashed password.
|
||||
ok := ValidatePassword(password, u.HashedPassword)
|
||||
if ok {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return User{}, false
|
||||
}
|
||||
|
||||
// mustGeneratePassword same as GeneratePassword but panics on errors.
|
||||
func mustGeneratePassword(userPassword string) []byte {
|
||||
hashed, err := GeneratePassword(userPassword)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return hashed
|
||||
}
|
||||
|
||||
// GeneratePassword will generate a hashed password for us based on the
|
||||
// user's input.
|
||||
func GeneratePassword(userPassword string) ([]byte, error) {
|
||||
return bcrypt.GenerateFromPassword([]byte(userPassword), bcrypt.DefaultCost)
|
||||
}
|
||||
|
||||
// ValidatePassword will check if passwords are matched.
|
||||
func ValidatePassword(userPassword string, hashed []byte) bool {
|
||||
err := bcrypt.CompareHashAndPassword(hashed, []byte(userPassword))
|
||||
return err == nil
|
||||
}
|
7
_examples/auth/jwt/tutorial/util/app.go
Normal file
7
_examples/auth/jwt/tutorial/util/app.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package util
|
||||
|
||||
// Constants for the application.
|
||||
const (
|
||||
Version = "0.0.1"
|
||||
AppName = "myapp"
|
||||
)
|
7
_examples/auth/jwt/tutorial/util/clock.go
Normal file
7
_examples/auth/jwt/tutorial/util/clock.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package util
|
||||
|
||||
import "time"
|
||||
|
||||
// Now is the default current time for the whole application.
|
||||
// Can be modified for testing or custom timezone.
|
||||
var Now = time.Now
|
25
_examples/auth/jwt/tutorial/util/password.go
Normal file
25
_examples/auth/jwt/tutorial/util/password.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package util
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// MustGeneratePassword same as GeneratePassword but panics on errors.
|
||||
func MustGeneratePassword(userPassword string) []byte {
|
||||
hashed, err := GeneratePassword(userPassword)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return hashed
|
||||
}
|
||||
|
||||
// GeneratePassword will generate a hashed password for us based on the
|
||||
// user's input.
|
||||
func GeneratePassword(userPassword string) ([]byte, error) {
|
||||
return bcrypt.GenerateFromPassword([]byte(userPassword), bcrypt.DefaultCost)
|
||||
}
|
||||
|
||||
// ValidatePassword will check if passwords are matched.
|
||||
func ValidatePassword(userPassword string, hashed []byte) bool {
|
||||
err := bcrypt.CompareHashAndPassword(hashed, []byte(userPassword))
|
||||
return err == nil
|
||||
}
|
23
_examples/auth/jwt/tutorial/util/uuid.go
Normal file
23
_examples/auth/jwt/tutorial/util/uuid.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
package util
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// MustGenerateUUID returns a new v4 UUID or panics.
|
||||
func MustGenerateUUID() string {
|
||||
id, err := GenerateUUID()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// GenerateUUID returns a new v4 UUID.
|
||||
func GenerateUUID() (string, error) {
|
||||
id, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return id.String(), nil
|
||||
}
|
|
@ -19,7 +19,8 @@ type Signer struct {
|
|||
// MaxAge to set "exp" and "iat".
|
||||
// Recommended value for access tokens: 15 minutes.
|
||||
// Defaults to 0, no limit.
|
||||
MaxAge time.Duration
|
||||
MaxAge time.Duration
|
||||
Options []SignOption
|
||||
|
||||
Encrypt func([]byte) ([]byte, error)
|
||||
}
|
||||
|
@ -33,11 +34,24 @@ type Signer struct {
|
|||
// signer := NewSigner(HS256, secret, 15*time.Minute)
|
||||
// token, err := signer.Sign(userClaims{Username: "kataras"})
|
||||
func NewSigner(signatureAlg Alg, signatureKey interface{}, maxAge time.Duration) *Signer {
|
||||
return &Signer{
|
||||
if signatureAlg == HS256 {
|
||||
// A tiny helper if the end-developer uses string instead of []byte for hmac keys.
|
||||
if k, ok := signatureKey.(string); ok {
|
||||
signatureKey = []byte(k)
|
||||
}
|
||||
}
|
||||
|
||||
s := &Signer{
|
||||
Alg: signatureAlg,
|
||||
Key: signatureKey,
|
||||
MaxAge: maxAge,
|
||||
}
|
||||
|
||||
if maxAge > 0 {
|
||||
s.Options = []SignOption{MaxAge(maxAge)}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// WithEncryption enables AES-GCM payload-only decryption.
|
||||
|
@ -53,7 +67,13 @@ func (s *Signer) WithEncryption(key, additionalData []byte) *Signer {
|
|||
|
||||
// Sign generates a new token based on the given "claims" which is valid up to "s.MaxAge".
|
||||
func (s *Signer) Sign(claims interface{}, opts ...SignOption) ([]byte, error) {
|
||||
return SignEncrypted(s.Alg, s.Key, s.Encrypt, claims, append([]SignOption{MaxAge(s.MaxAge)}, opts...)...)
|
||||
if len(opts) > 0 {
|
||||
opts = append(opts, s.Options...)
|
||||
} else {
|
||||
opts = s.Options
|
||||
}
|
||||
|
||||
return SignEncrypted(s.Alg, s.Key, s.Encrypt, claims, opts...)
|
||||
}
|
||||
|
||||
// NewTokenPair accepts the access and refresh claims plus the life time duration for the refresh token
|
||||
|
|
|
@ -80,6 +80,13 @@ type Verifier struct {
|
|||
// Get the context user:
|
||||
// username, err := ctx.User().GetUsername()
|
||||
func NewVerifier(signatureAlg Alg, signatureKey interface{}, validators ...TokenValidator) *Verifier {
|
||||
if signatureAlg == HS256 {
|
||||
// A tiny helper if the end-developer uses string instead of []byte for hmac keys.
|
||||
if k, ok := signatureKey.(string); ok {
|
||||
signatureKey = []byte(k)
|
||||
}
|
||||
}
|
||||
|
||||
return &Verifier{
|
||||
Alg: signatureAlg,
|
||||
Key: signatureKey,
|
||||
|
|
Loading…
Reference in New Issue
Block a user