diff --git a/_examples/README.md b/_examples/README.md index 03706f6d..3eed3807 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -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) diff --git a/_examples/auth/jwt/tutorial/api.go b/_examples/auth/jwt/tutorial/api.go deleted file mode 100644 index 688551e9..00000000 --- a/_examples/auth/jwt/tutorial/api.go +++ /dev/null @@ -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) { - -} diff --git a/_examples/auth/jwt/tutorial/api/auth.go b/_examples/auth/jwt/tutorial/api/auth.go new file mode 100644 index 00000000..b4c69077 --- /dev/null +++ b/_examples/auth/jwt/tutorial/api/auth.go @@ -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 +} diff --git a/_examples/auth/jwt/tutorial/api/todo.go b/_examples/auth/jwt/tutorial/api/todo.go new file mode 100644 index 00000000..317494e8 --- /dev/null +++ b/_examples/auth/jwt/tutorial/api/todo.go @@ -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) + } +} +*/ diff --git a/_examples/auth/jwt/tutorial/domain/model/role.go b/_examples/auth/jwt/tutorial/domain/model/role.go new file mode 100644 index 00000000..00fd1c25 --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/model/role.go @@ -0,0 +1,9 @@ +package model + +// Role represents a role. +type Role string + +const ( + // Admin represents the Admin access role. + Admin Role = "admin" +) diff --git a/_examples/auth/jwt/tutorial/domain/model/todo.go b/_examples/auth/jwt/tutorial/domain/model/todo.go new file mode 100644 index 00000000..d0a2ea27 --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/model/todo.go @@ -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. +} diff --git a/_examples/auth/jwt/tutorial/domain/model/user.go b/_examples/auth/jwt/tutorial/domain/model/user.go new file mode 100644 index 00000000..9fa8bcdf --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/model/user.go @@ -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"` +} diff --git a/_examples/auth/jwt/tutorial/domain/repository/samples.go b/_examples/auth/jwt/tutorial/domain/repository/samples.go new file mode 100644 index 00000000..b7d267ba --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/repository/samples.go @@ -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 +} diff --git a/_examples/auth/jwt/tutorial/domain/repository/todo_repository.go b/_examples/auth/jwt/tutorial/domain/repository/todo_repository.go new file mode 100644 index 00000000..a3c8429f --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/repository/todo_repository.go @@ -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 +} diff --git a/_examples/auth/jwt/tutorial/domain/repository/user_repository.go b/_examples/auth/jwt/tutorial/domain/repository/user_repository.go new file mode 100644 index 00000000..b6e0bd76 --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/repository/user_repository.go @@ -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 +} diff --git a/_examples/auth/jwt/tutorial/go-client/README.md b/_examples/auth/jwt/tutorial/go-client/README.md new file mode 100644 index 00000000..f7a96a9c --- /dev/null +++ b/_examples/auth/jwt/tutorial/go-client/README.md @@ -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} +``` diff --git a/_examples/auth/jwt/tutorial/go-client/client.go b/_examples/auth/jwt/tutorial/go-client/client.go new file mode 100644 index 00000000..e96b769f --- /dev/null +++ b/_examples/auth/jwt/tutorial/go-client/client.go @@ -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) +} diff --git a/_examples/auth/jwt/tutorial/go-client/main.go b/_examples/auth/jwt/tutorial/go-client/main.go new file mode 100644 index 00000000..d22688e1 --- /dev/null +++ b/_examples/auth/jwt/tutorial/go-client/main.go @@ -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 +} diff --git a/_examples/auth/jwt/tutorial/go.mod b/_examples/auth/jwt/tutorial/go.mod index 99e567cc..3bcd36bd 100644 --- a/_examples/auth/jwt/tutorial/go.mod +++ b/_examples/auth/jwt/tutorial/go.mod @@ -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 => ../../../../ diff --git a/_examples/auth/jwt/tutorial/main.go b/_examples/auth/jwt/tutorial/main.go index 2dee4c00..8be43081 100644 --- a/_examples/auth/jwt/tutorial/main.go +++ b/_examples/auth/jwt/tutorial/main.go @@ -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) -} diff --git a/_examples/auth/jwt/tutorial/user.go b/_examples/auth/jwt/tutorial/user.go deleted file mode 100644 index 3f9bb9b1..00000000 --- a/_examples/auth/jwt/tutorial/user.go +++ /dev/null @@ -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 -} diff --git a/_examples/auth/jwt/tutorial/util/app.go b/_examples/auth/jwt/tutorial/util/app.go new file mode 100644 index 00000000..7a991f01 --- /dev/null +++ b/_examples/auth/jwt/tutorial/util/app.go @@ -0,0 +1,7 @@ +package util + +// Constants for the application. +const ( + Version = "0.0.1" + AppName = "myapp" +) diff --git a/_examples/auth/jwt/tutorial/util/clock.go b/_examples/auth/jwt/tutorial/util/clock.go new file mode 100644 index 00000000..34e35eb2 --- /dev/null +++ b/_examples/auth/jwt/tutorial/util/clock.go @@ -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 diff --git a/_examples/auth/jwt/tutorial/util/password.go b/_examples/auth/jwt/tutorial/util/password.go new file mode 100644 index 00000000..c18d0a91 --- /dev/null +++ b/_examples/auth/jwt/tutorial/util/password.go @@ -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 +} diff --git a/_examples/auth/jwt/tutorial/util/uuid.go b/_examples/auth/jwt/tutorial/util/uuid.go new file mode 100644 index 00000000..0269d62d --- /dev/null +++ b/_examples/auth/jwt/tutorial/util/uuid.go @@ -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 +} diff --git a/middleware/jwt/signer.go b/middleware/jwt/signer.go index a121990a..583f8c00 100644 --- a/middleware/jwt/signer.go +++ b/middleware/jwt/signer.go @@ -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 diff --git a/middleware/jwt/verifier.go b/middleware/jwt/verifier.go index 6df2788e..b5fd75c0 100644 --- a/middleware/jwt/verifier.go +++ b/middleware/jwt/verifier.go @@ -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,