mirror of
https://github.com/kataras/iris.git
synced 2025-02-02 15:30:36 +01:00
first release of SSO package and more examples
This commit is contained in:
parent
45d693850b
commit
cf36063adf
4
NOTICE
4
NOTICE
|
@ -44,6 +44,10 @@ Revision ID: 5fc50a00491616d5cd0cbce3abd8b699838e25ca
|
||||||
easyjson 8ab5ff9cd8e4e43 https://github.com/mailru/easyjson
|
easyjson 8ab5ff9cd8e4e43 https://github.com/mailru/easyjson
|
||||||
2e8b79f6c47d324
|
2e8b79f6c47d324
|
||||||
a31dd803cf
|
a31dd803cf
|
||||||
|
|
||||||
|
securecookie e59506cc896acb7 https://github.com/gorilla/securecookie
|
||||||
|
f7bf732d4fdf5e2
|
||||||
|
5f7ccd8983
|
||||||
semver 4487282d78122a2 https://github.com/blang/semver
|
semver 4487282d78122a2 https://github.com/blang/semver
|
||||||
45e413d7515e7c5
|
45e413d7515e7c5
|
||||||
16b70c33fd
|
16b70c33fd
|
||||||
|
|
|
@ -124,6 +124,7 @@
|
||||||
* [Embedded Single Page Application with other routes](file-server/single-page-application/embedded-single-page-application-with-other-routes/main.go)
|
* [Embedded Single Page Application with other routes](file-server/single-page-application/embedded-single-page-application-with-other-routes/main.go)
|
||||||
* [Upload File](file-server/upload-file/main.go)
|
* [Upload File](file-server/upload-file/main.go)
|
||||||
* [Upload Multiple Files](file-server/upload-files/main.go)
|
* [Upload Multiple Files](file-server/upload-files/main.go)
|
||||||
|
* [WebDAV](file-server/webdav/main.go)
|
||||||
* View
|
* View
|
||||||
* [Overview](view/overview/main.go)
|
* [Overview](view/overview/main.go)
|
||||||
* [Layout](view/layout)
|
* [Layout](view/layout)
|
||||||
|
@ -225,6 +226,7 @@
|
||||||
* [Blocklist](auth/jwt/blocklist/main.go)
|
* [Blocklist](auth/jwt/blocklist/main.go)
|
||||||
* [Refresh Token](auth/jwt/refresh-token/main.go)
|
* [Refresh Token](auth/jwt/refresh-token/main.go)
|
||||||
* [Tutorial](auth/jwt/tutorial)
|
* [Tutorial](auth/jwt/tutorial)
|
||||||
|
* [SSO](auth/sso) **NEW (GO 1.18 Generics required)**
|
||||||
* [JWT (community edition)](https://github.com/iris-contrib/middleware/tree/v12/jwt/_example/main.go)
|
* [JWT (community edition)](https://github.com/iris-contrib/middleware/tree/v12/jwt/_example/main.go)
|
||||||
* [OAUth2](auth/goth/main.go)
|
* [OAUth2](auth/goth/main.go)
|
||||||
* [Manage Permissions](auth/permissions/main.go)
|
* [Manage Permissions](auth/permissions/main.go)
|
||||||
|
@ -277,6 +279,7 @@
|
||||||
* [Authenticated Controller](mvc/authenticated-controller/main.go)
|
* [Authenticated Controller](mvc/authenticated-controller/main.go)
|
||||||
* [Versioned Controller](mvc/versioned-controller/main.go)
|
* [Versioned Controller](mvc/versioned-controller/main.go)
|
||||||
* [Websocket Controller](mvc/websocket)
|
* [Websocket Controller](mvc/websocket)
|
||||||
|
* [Websocket + Authentication (SSO)](mvc/websocket-sso) **NEW (GO 1.18 Generics required)**
|
||||||
* [Register Middleware](mvc/middleware)
|
* [Register Middleware](mvc/middleware)
|
||||||
* [gRPC](mvc/grpc-compatible)
|
* [gRPC](mvc/grpc-compatible)
|
||||||
* [gRPC Bidirectional Stream](mvc/grpc-compatible-bidirectional-stream)
|
* [gRPC Bidirectional Stream](mvc/grpc-compatible-bidirectional-stream)
|
||||||
|
|
12
_examples/auth/sso/README.md
Normal file
12
_examples/auth/sso/README.md
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# SSO (Single Sign On)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
1. GET/POST: http://localhost:8080/signin
|
||||||
|
2. GET: http://localhost:8080/member
|
||||||
|
3. GET: http://localhost:8080/owner
|
||||||
|
4. POST: http://localhost:8080/refresh
|
||||||
|
5. GET: http://localhost:8080/signout
|
||||||
|
6. GET: http://localhost:8080/signout-all
|
135
_examples/auth/sso/main.go
Normal file
135
_examples/auth/sso/main.go
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
//go:build go1.18
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12"
|
||||||
|
"github.com/kataras/iris/v12/sso"
|
||||||
|
)
|
||||||
|
|
||||||
|
func allowRole(role AccessRole) sso.TVerify[User] {
|
||||||
|
return func(u User) error {
|
||||||
|
if !u.Role.Allow(role) {
|
||||||
|
return fmt.Errorf("invalid role")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFilename = "./sso.yml"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := iris.New()
|
||||||
|
app.RegisterView(iris.Blocks(iris.Dir("./views"), ".html").
|
||||||
|
LayoutDir("layouts").
|
||||||
|
Layout("main"))
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Easiest 1-liner way, load from configuration and initialize a new sso instance:
|
||||||
|
s := sso.MustLoad[User]("./sso.yml")
|
||||||
|
// Bind a configuration from file:
|
||||||
|
var c sso.Configuration
|
||||||
|
c.BindFile("./sso.yml")
|
||||||
|
s, err := sso.New[User](c)
|
||||||
|
// OR create new programmatically configuration:
|
||||||
|
config := sso.Configuration{
|
||||||
|
...fields
|
||||||
|
}
|
||||||
|
s, err := sso.New[User](config)
|
||||||
|
// OR generate a new configuration:
|
||||||
|
config := sso.MustGenerateConfiguration()
|
||||||
|
s, err := sso.New[User](config)
|
||||||
|
// OR generate a new config and save it if cannot open the config file.
|
||||||
|
if _, err := os.Stat(configFilename); err != nil {
|
||||||
|
generatedConfig := sso.MustGenerateConfiguration()
|
||||||
|
configContents, err := generatedConfig.ToYAML()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(configFilename, configContents, 0600)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 1. Load configuration from a file.
|
||||||
|
ssoConfig, err := sso.LoadConfiguration(configFilename)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Initialize a new sso instance for "User" claims (generics: go1.18 +).
|
||||||
|
s, err := sso.New[User](ssoConfig)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Add a custom provider, in our case is just a memory-based one.
|
||||||
|
s.AddProvider(NewProvider())
|
||||||
|
// 3.1. Optionally set a custom error handler.
|
||||||
|
// s.SetErrorHandler(new(sso.DefaultErrorHandler))
|
||||||
|
|
||||||
|
app.Get("/signin", renderSigninForm)
|
||||||
|
// 4. generate token pairs.
|
||||||
|
app.Post("/signin", s.SigninHandler)
|
||||||
|
// 5. refresh token pairs.
|
||||||
|
app.Post("/refresh", s.RefreshHandler)
|
||||||
|
// 6. calls the provider's InvalidateToken method.
|
||||||
|
app.Get("/signout", s.SignoutHandler)
|
||||||
|
// 7. calls the provider's InvalidateTokens method.
|
||||||
|
app.Get("/signout-all", s.SignoutAllHandler)
|
||||||
|
|
||||||
|
// 8.1. allow access for users with "Member" role.
|
||||||
|
app.Get("/member", s.VerifyHandler(allowRole(Member)), renderMemberPage(s))
|
||||||
|
// 8.2. allow access for users with "Owner" role.
|
||||||
|
app.Get("/owner", s.VerifyHandler(allowRole(Owner)), renderOwnerPage(s))
|
||||||
|
|
||||||
|
/* Subdomain user verify:
|
||||||
|
app.Subdomain("owner", s.VerifyHandler(allowRole(Owner))).Get("/", renderOwnerPage(s))
|
||||||
|
*/
|
||||||
|
app.Listen(":8080", iris.WithOptimizations) // Setup HTTPS/TLS for production instead.
|
||||||
|
/* Test subdomain user verify, one way is ingrok,
|
||||||
|
add the below to the arguments above:
|
||||||
|
, iris.WithConfiguration(iris.Configuration{
|
||||||
|
EnableOptmizations: true,
|
||||||
|
Tunneling: iris.TunnelingConfiguration{
|
||||||
|
AuthToken: "YOUR_AUTH_TOKEN",
|
||||||
|
Region: "us",
|
||||||
|
Tunnels: []tunnel.Tunnel{
|
||||||
|
{
|
||||||
|
Name: "Iris SSO (Test)",
|
||||||
|
Addr: ":8080",
|
||||||
|
Hostname: "YOUR_DOMAIN",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Iris SSO (Test Subdomain)",
|
||||||
|
Addr: ":8080",
|
||||||
|
Hostname: "owner.YOUR_DOMAIN",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})*/
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSigninForm(ctx iris.Context) {
|
||||||
|
ctx.View("signin", iris.Map{"Title": "Signin Page"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMemberPage(s *sso.SSO[User]) iris.Handler {
|
||||||
|
return func(ctx iris.Context) {
|
||||||
|
user := s.GetUser(ctx)
|
||||||
|
ctx.Writef("Hello member: %s\n", user.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderOwnerPage(s *sso.SSO[User]) iris.Handler {
|
||||||
|
return func(ctx iris.Context) {
|
||||||
|
user := s.GetUser(ctx)
|
||||||
|
ctx.Writef("Hello owner: %s\n", user.Email)
|
||||||
|
}
|
||||||
|
}
|
32
_examples/auth/sso/sso.yml
Normal file
32
_examples/auth/sso/sso.yml
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
Cookie: # optional.
|
||||||
|
Name: "iris_sso"
|
||||||
|
Hash: "D*G-KaPdSgUkXp2s5v8y/B?E(H+MbQeThWmYq3t6w9z$C&F)J@NcRfUjXn2r4u7x" # length of 64 characters (512-bit).
|
||||||
|
Block: "VkYp3s6v9y$B&E)H@McQfTjWmZq4t7w!" # length of 32 characters (256-bit).
|
||||||
|
Keys:
|
||||||
|
- ID: IRIS_SSO_ACCESS # required.
|
||||||
|
Alg: EdDSA
|
||||||
|
MaxAge: 2h # 2 hours lifetime for access tokens.
|
||||||
|
Private: |+
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MC4CAQAwBQYDK2VwBCIEIFdZWoDdFny5SMnP9Fyfr8bafi/B527EVZh8JJjDTIFO
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
Public: |+
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAzpgjKSr9E032DX+foiOxq1QDsbzjLxagTN+yVpGWZB4=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
- ID: IRIS_SSO_REFRESH # optional. Good practise to have it though.
|
||||||
|
Alg: EdDSA
|
||||||
|
# 1 month lifetime for refresh tokens,
|
||||||
|
# after that period the user has to signin again.
|
||||||
|
MaxAge: 720h
|
||||||
|
Private: |+
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MC4CAQAwBQYDK2VwBCIEIHJ1aoIjA2sRp5eqGjGR3/UMucrHbBdBv9p8uwfzZ1KZ
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
Public: |+
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAsKKAr+kDtfAqwG7cZdoEAfh9jHt9W8qi9ur5AA1KQAQ=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
# Example of setting a binary form of the encryption key for refresh tokens,
|
||||||
|
# it could be a "string" as well.
|
||||||
|
EncryptionKey: !!binary stSNLTu91YyihPxzeEOXKwGVMG00CjcC/68G8nMgmqA=
|
33
_examples/auth/sso/user.go
Normal file
33
_examples/auth/sso/user.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
//go:build go1.18
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
type AccessRole uint16
|
||||||
|
|
||||||
|
func (r AccessRole) Is(v AccessRole) bool {
|
||||||
|
return r&v != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r AccessRole) Allow(v AccessRole) bool {
|
||||||
|
return r&v >= v
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
InvalidAccessRole AccessRole = 1 << iota
|
||||||
|
Read
|
||||||
|
Write
|
||||||
|
Delete
|
||||||
|
|
||||||
|
Owner = Read | Write | Delete
|
||||||
|
Member = Read | Write
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role AccessRole `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) GetID() string {
|
||||||
|
return u.ID
|
||||||
|
}
|
100
_examples/auth/sso/user_provider.go
Normal file
100
_examples/auth/sso/user_provider.go
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
//go:build go1.18
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12/sso"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
dataset []User
|
||||||
|
|
||||||
|
invalidated map[string]struct{} // key = token. Entry is blocked.
|
||||||
|
invalidatedAll map[string]int64 // key = user id, value = timestamp. Issued before is consider invalid.
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProvider() *Provider {
|
||||||
|
return &Provider{
|
||||||
|
dataset: []User{
|
||||||
|
{
|
||||||
|
ID: "id-1",
|
||||||
|
Email: "kataras2006@hotmail.com",
|
||||||
|
Role: Owner,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "id-2",
|
||||||
|
Email: "example@example.com",
|
||||||
|
Role: Member,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invalidated: make(map[string]struct{}),
|
||||||
|
invalidatedAll: make(map[string]int64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Signin(ctx context.Context, username, password string) (User, error) { // fired on SigninHandler.
|
||||||
|
// your database...
|
||||||
|
for _, user := range p.dataset {
|
||||||
|
if user.Email == username {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return User{}, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) ValidateToken(ctx context.Context, standardClaims sso.StandardClaims, u User) error { // fired on VerifyHandler.
|
||||||
|
// your database and checks of blocked tokens...
|
||||||
|
|
||||||
|
// check for specific token ids.
|
||||||
|
p.mu.RLock()
|
||||||
|
_, tokenBlocked := p.invalidated[standardClaims.ID]
|
||||||
|
if !tokenBlocked {
|
||||||
|
// this will disallow refresh tokens with origin jwt token id as the blocked access token as well.
|
||||||
|
if standardClaims.OriginID != "" {
|
||||||
|
_, tokenBlocked = p.invalidated[standardClaims.OriginID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
if tokenBlocked {
|
||||||
|
return fmt.Errorf("token was invalidated")
|
||||||
|
}
|
||||||
|
//
|
||||||
|
|
||||||
|
// check all tokens issuet before the "InvalidateToken" method was fired for this user.
|
||||||
|
p.mu.RLock()
|
||||||
|
ts, oldUserBlocked := p.invalidatedAll[u.ID]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
if oldUserBlocked && standardClaims.IssuedAt <= ts {
|
||||||
|
return fmt.Errorf("token was invalidated")
|
||||||
|
}
|
||||||
|
//
|
||||||
|
|
||||||
|
return nil // else valid.
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) InvalidateToken(ctx context.Context, standardClaims sso.StandardClaims, u User) error { // fired on SignoutHandler.
|
||||||
|
// invalidate this specific token.
|
||||||
|
p.mu.Lock()
|
||||||
|
p.invalidated[standardClaims.ID] = struct{}{}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) InvalidateTokens(ctx context.Context, u User) error { // fired on SignoutAllHandler.
|
||||||
|
// invalidate all previous tokens came from "u".
|
||||||
|
p.mu.Lock()
|
||||||
|
p.invalidatedAll[u.ID] = time.Now().Unix()
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
30
_examples/auth/sso/views/layouts/main.html
Normal file
30
_examples/auth/sso/views/layouts/main.html
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ if .Title }}{{ .Title }}{{ else }}Default Main Title{{ end }}</title>
|
||||||
|
</head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<main>{{ template "content" . }}</main>
|
||||||
|
<footer style="position: fixed; bottom: 0; width: 100%;">{{ partial "partials/footer" .}}</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
1
_examples/auth/sso/views/partials/footer.html
Normal file
1
_examples/auth/sso/views/partials/footer.html
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<i>Iris Web Framework © 2022</i>
|
9
_examples/auth/sso/views/signin.html
Normal file
9
_examples/auth/sso/views/signin.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<div class="user_signin">
|
||||||
|
<form action="" method="post">
|
||||||
|
<label for="username">Email:</label>
|
||||||
|
<input name="username" type="email" />
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input name="password" type="password" />
|
||||||
|
<input type="submit" value="Sign in" />
|
||||||
|
</form>
|
||||||
|
</div>
|
57
_examples/file-server/webdav/main.go
Normal file
57
_examples/file-server/webdav/main.go
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12"
|
||||||
|
"github.com/kataras/iris/v12/middleware/accesslog"
|
||||||
|
"github.com/kataras/iris/v12/middleware/recover"
|
||||||
|
|
||||||
|
"golang.org/x/net/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := iris.New()
|
||||||
|
|
||||||
|
app.Logger().SetLevel("debug")
|
||||||
|
app.Use(recover.New())
|
||||||
|
app.Use(accesslog.New(os.Stdout).Handler)
|
||||||
|
|
||||||
|
webdavHandler := &webdav.Handler{
|
||||||
|
FileSystem: webdav.Dir("./"),
|
||||||
|
LockSystem: webdav.NewMemLS(),
|
||||||
|
Logger: func(r *http.Request, err error) {
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.HandleMany(strings.Join(iris.WebDAVMethods, " "), "/{p:path}", iris.FromStd(webdavHandler))
|
||||||
|
|
||||||
|
app.Listen(":8080",
|
||||||
|
iris.WithoutServerError(iris.ErrServerClosed),
|
||||||
|
iris.WithoutPathCorrection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test with cURL or postman:
|
||||||
|
|
||||||
|
* List files:
|
||||||
|
curl --location --request PROPFIND 'http://localhost:8080'
|
||||||
|
* Get File:
|
||||||
|
curl --location --request GET 'http://localhost:8080/test.txt'
|
||||||
|
* Upload File:
|
||||||
|
curl --location --request PUT 'http://localhost:8080/newfile.txt' \
|
||||||
|
--header 'Content-Type: text/plain' \
|
||||||
|
--data-raw 'This is a new file!'
|
||||||
|
* Copy File:
|
||||||
|
curl --location --request COPY 'http://localhost:8080/test.txt' \
|
||||||
|
--header 'Destination: newdir/test.txt'
|
||||||
|
* Create New Directory:
|
||||||
|
curl --location --request MKCOL 'http://localhost:8080/anewdir/'
|
||||||
|
|
||||||
|
And e.t.c.
|
||||||
|
*/
|
0
_examples/file-server/webdav/newdir/.gitkeep
Normal file
0
_examples/file-server/webdav/newdir/.gitkeep
Normal file
1
_examples/file-server/webdav/test.txt
Normal file
1
_examples/file-server/webdav/test.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Hello, world!
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
pb "github.com/kataras/iris/v12/_examples/mvc/grpc-compatible/helloworld"
|
pb "github.com/kataras/iris/v12/_examples/mvc/grpc-compatible/helloworld"
|
||||||
|
|
||||||
|
@ -47,17 +48,32 @@ func newApp() *iris.Application {
|
||||||
// Register MVC application controller for gRPC services.
|
// Register MVC application controller for gRPC services.
|
||||||
// You can bind as many mvc gRpc services in the same Party or app,
|
// You can bind as many mvc gRpc services in the same Party or app,
|
||||||
// as the ServiceName differs.
|
// as the ServiceName differs.
|
||||||
mvc.New(app).Handle(ctrl, mvc.GRPC{
|
mvc.New(app).
|
||||||
Server: grpcServer, // Required.
|
Register(new(myService)).
|
||||||
ServiceName: "helloworld.Greeter", // Required.
|
Handle(ctrl, mvc.GRPC{
|
||||||
Strict: false,
|
Server: grpcServer, // Required.
|
||||||
})
|
ServiceName: "helloworld.Greeter", // Required.
|
||||||
|
Strict: false,
|
||||||
|
})
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type service interface {
|
||||||
|
DoSomething() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type myService struct{}
|
||||||
|
|
||||||
|
func (s *myService) DoSomething() error {
|
||||||
|
log.Println("service: DoSomething")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type myController struct {
|
type myController struct {
|
||||||
// Ctx iris.Context
|
// Ctx iris.Context
|
||||||
|
|
||||||
|
SingletonDependency service
|
||||||
}
|
}
|
||||||
|
|
||||||
// SayHello implements helloworld.GreeterServer.
|
// SayHello implements helloworld.GreeterServer.
|
||||||
|
@ -70,5 +86,10 @@ type myController struct {
|
||||||
// @Success 200 {string} string "Hello {name}"
|
// @Success 200 {string} string "Hello {name}"
|
||||||
// @Router /helloworld.Greeter/SayHello [post]
|
// @Router /helloworld.Greeter/SayHello [post]
|
||||||
func (c *myController) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
|
func (c *myController) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
|
||||||
|
err := c.SingletonDependency.DoSomething()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
|
return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
|
||||||
}
|
}
|
||||||
|
|
106
_examples/mvc/websocket-sso/browser/index.html
Normal file
106
_examples/mvc/websocket-sso/browser/index.html
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Online visitors MVC example</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, "San Francisco", "Helvetica Neue", "Noto", "Roboto", "Calibri Light", sans-serif;
|
||||||
|
color: #212121;
|
||||||
|
font-size: 1.0em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 750px;
|
||||||
|
margin: auto;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#online_visitors {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<span id="online_visitors">1 online visitor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- the message's input -->
|
||||||
|
<input id="input" type="text" />
|
||||||
|
|
||||||
|
<!-- when clicked then a websocket event will be sent to the server, at this example we registered the 'chat' -->
|
||||||
|
<button id="sendBtn" disabled>Send</button>
|
||||||
|
|
||||||
|
<!-- the messages will be shown here -->
|
||||||
|
<pre id="output"></pre>
|
||||||
|
<!-- import the iris client-side library for browser from a CDN or locally.
|
||||||
|
However, `neffos.(min.)js` is a NPM package too so alternatively,
|
||||||
|
you can use it as dependency on your package.json and all nodejs-npm tooling become available:
|
||||||
|
see the "browserify" example for more-->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/neffos.js@latest/dist/neffos.min.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
const wsURL = "ws://localhost:8080/protected/ws"
|
||||||
|
var outputTxt = document.getElementById("output");
|
||||||
|
function addMessage(msg) {
|
||||||
|
outputTxt.innerHTML += msg + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runExample() {
|
||||||
|
try {
|
||||||
|
const conn = await neffos.dial(wsURL, {
|
||||||
|
default: { // "default" namespace.
|
||||||
|
_OnNamespaceConnected: function (nsConn, msg) {
|
||||||
|
if (nsConn.conn.wasReconnected()) {
|
||||||
|
addMessage("re-connected after " + nsConn.conn.reconnectTries.toString() + " trie(s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputTxt = document.getElementById("input");
|
||||||
|
let sendBtn = document.getElementById("sendBtn");
|
||||||
|
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.onclick = function () {
|
||||||
|
const input = inputTxt.value;
|
||||||
|
inputTxt.value = "";
|
||||||
|
nsConn.emit("OnChat", input);
|
||||||
|
addMessage("Me: " + input);
|
||||||
|
};
|
||||||
|
|
||||||
|
addMessage("connected to namespace: " + msg.Namespace);
|
||||||
|
},
|
||||||
|
_OnNamespaceDisconnect: function (nsConn, msg) {
|
||||||
|
addMessage("disconnected from namespace: " + msg.Namespace);
|
||||||
|
},
|
||||||
|
OnChat: function (nsConn, msg) { // "OnChat" event.
|
||||||
|
console.log(msg);
|
||||||
|
|
||||||
|
addMessage(msg.Body);
|
||||||
|
},
|
||||||
|
OnVisit: function (nsConn, msg) {
|
||||||
|
const newCount = Number(msg.Body); // or parseInt.
|
||||||
|
console.log("visit websocket event with newCount of: ", newCount);
|
||||||
|
|
||||||
|
var text = "1 online visitor";
|
||||||
|
if (newCount > 1) {
|
||||||
|
text = newCount + " online visitors";
|
||||||
|
}
|
||||||
|
document.getElementById("online_visitors").innerHTML = text;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.connect("default");
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runExample();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
72
_examples/mvc/websocket-sso/main.go
Normal file
72
_examples/mvc/websocket-sso/main.go
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
//go:build go1.18
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12"
|
||||||
|
"github.com/kataras/iris/v12/mvc"
|
||||||
|
"github.com/kataras/iris/v12/sso"
|
||||||
|
"github.com/kataras/iris/v12/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// $ go run .
|
||||||
|
func main() {
|
||||||
|
app := newApp()
|
||||||
|
|
||||||
|
// http://localhost:8080/signin (creds: kataras2006@hotmail.com 123456)
|
||||||
|
// http://localhost:8080/protected
|
||||||
|
// http://localhost:8080/signout
|
||||||
|
app.Listen(":8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newApp() *iris.Application {
|
||||||
|
app := iris.New()
|
||||||
|
|
||||||
|
// Auth part.
|
||||||
|
app.RegisterView(iris.Blocks(iris.Dir("./views"), ".html").
|
||||||
|
LayoutDir("layouts").
|
||||||
|
Layout("main"))
|
||||||
|
|
||||||
|
s := sso.MustLoad[User]("./sso.yml")
|
||||||
|
s.AddProvider(NewProvider())
|
||||||
|
|
||||||
|
app.Get("/signin", renderSigninForm)
|
||||||
|
app.Post("/signin", s.SigninHandler)
|
||||||
|
app.Get("/signout", s.SignoutHandler)
|
||||||
|
//
|
||||||
|
|
||||||
|
websocketAPI := app.Party("/protected")
|
||||||
|
websocketAPI.Use(s.VerifyHandler())
|
||||||
|
websocketAPI.HandleDir("/", iris.Dir("./browser")) // render the ./browser/index.html.
|
||||||
|
|
||||||
|
websocketMVC := mvc.New(websocketAPI)
|
||||||
|
websocketMVC.HandleWebsocket(new(websocketController))
|
||||||
|
websocketServer := websocket.New(websocket.DefaultGorillaUpgrader, websocketMVC)
|
||||||
|
websocketAPI.Get("/ws", s.VerifyHandler() /* optional */, websocket.Handler(websocketServer))
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSigninForm(ctx iris.Context) {
|
||||||
|
ctx.View("signin", iris.Map{"Title": "Signin Page"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type websocketController struct {
|
||||||
|
*websocket.NSConn `stateless:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *websocketController) Namespace() string {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *websocketController) OnChat(msg websocket.Message) error {
|
||||||
|
ctx := websocket.GetContext(c.Conn)
|
||||||
|
user := sso.GetUser[User](ctx)
|
||||||
|
|
||||||
|
msg.Body = []byte(fmt.Sprintf("%s: %s", user.Email, string(msg.Body)))
|
||||||
|
c.Conn.Server().Broadcast(c, msg)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
32
_examples/mvc/websocket-sso/sso.yml
Normal file
32
_examples/mvc/websocket-sso/sso.yml
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
Cookie: # optional.
|
||||||
|
Name: "iris_sso"
|
||||||
|
Hash: "D*G-KaPdSgUkXp2s5v8y/B?E(H+MbQeThWmYq3t6w9z$C&F)J@NcRfUjXn2r4u7x" # length of 64 characters (512-bit).
|
||||||
|
Block: "VkYp3s6v9y$B&E)H@McQfTjWmZq4t7w!" # length of 32 characters (256-bit).
|
||||||
|
Keys:
|
||||||
|
- ID: IRIS_SSO_ACCESS # required.
|
||||||
|
Alg: EdDSA
|
||||||
|
MaxAge: 2h # 2 hours lifetime for access tokens.
|
||||||
|
Private: |+
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MC4CAQAwBQYDK2VwBCIEIFdZWoDdFny5SMnP9Fyfr8bafi/B527EVZh8JJjDTIFO
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
Public: |+
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAzpgjKSr9E032DX+foiOxq1QDsbzjLxagTN+yVpGWZB4=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
- ID: IRIS_SSO_REFRESH # optional. Good practise to have it though.
|
||||||
|
Alg: EdDSA
|
||||||
|
# 1 month lifetime for refresh tokens,
|
||||||
|
# after that period the user has to signin again.
|
||||||
|
MaxAge: 720h
|
||||||
|
Private: |+
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MC4CAQAwBQYDK2VwBCIEIHJ1aoIjA2sRp5eqGjGR3/UMucrHbBdBv9p8uwfzZ1KZ
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
Public: |+
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAsKKAr+kDtfAqwG7cZdoEAfh9jHt9W8qi9ur5AA1KQAQ=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
# Example of setting a binary form of the encryption key for refresh tokens,
|
||||||
|
# it could be a "string" as well.
|
||||||
|
EncryptionKey: !!binary stSNLTu91YyihPxzeEOXKwGVMG00CjcC/68G8nMgmqA=
|
33
_examples/mvc/websocket-sso/user.go
Normal file
33
_examples/mvc/websocket-sso/user.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
//go:build go1.18
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
type AccessRole uint16
|
||||||
|
|
||||||
|
func (r AccessRole) Is(v AccessRole) bool {
|
||||||
|
return r&v != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r AccessRole) Allow(v AccessRole) bool {
|
||||||
|
return r&v >= v
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
InvalidAccessRole AccessRole = 1 << iota
|
||||||
|
Read
|
||||||
|
Write
|
||||||
|
Delete
|
||||||
|
|
||||||
|
Owner = Read | Write | Delete
|
||||||
|
Member = Read | Write
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role AccessRole `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) GetID() string {
|
||||||
|
return u.ID
|
||||||
|
}
|
100
_examples/mvc/websocket-sso/user_provider.go
Normal file
100
_examples/mvc/websocket-sso/user_provider.go
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
//go:build go1.18
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12/sso"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
dataset []User
|
||||||
|
|
||||||
|
invalidated map[string]struct{} // key = token. Entry is blocked.
|
||||||
|
invalidatedAll map[string]int64 // key = user id, value = timestamp. Issued before is consider invalid.
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProvider() *Provider {
|
||||||
|
return &Provider{
|
||||||
|
dataset: []User{
|
||||||
|
{
|
||||||
|
ID: "id-1",
|
||||||
|
Email: "kataras2006@hotmail.com",
|
||||||
|
Role: Owner,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "id-2",
|
||||||
|
Email: "example@example.com",
|
||||||
|
Role: Member,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invalidated: make(map[string]struct{}),
|
||||||
|
invalidatedAll: make(map[string]int64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Signin(ctx context.Context, username, password string) (User, error) { // fired on SigninHandler.
|
||||||
|
// your database...
|
||||||
|
for _, user := range p.dataset {
|
||||||
|
if user.Email == username {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return User{}, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) ValidateToken(ctx context.Context, standardClaims sso.StandardClaims, u User) error { // fired on VerifyHandler.
|
||||||
|
// your database and checks of blocked tokens...
|
||||||
|
|
||||||
|
// check for specific token ids.
|
||||||
|
p.mu.RLock()
|
||||||
|
_, tokenBlocked := p.invalidated[standardClaims.ID]
|
||||||
|
if !tokenBlocked {
|
||||||
|
// this will disallow refresh tokens with issuer as the blocked access token as well.
|
||||||
|
if standardClaims.Issuer != "" {
|
||||||
|
_, tokenBlocked = p.invalidated[standardClaims.Issuer]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
if tokenBlocked {
|
||||||
|
return fmt.Errorf("token was invalidated")
|
||||||
|
}
|
||||||
|
//
|
||||||
|
|
||||||
|
// check all tokens issuet before the "InvalidateToken" method was fired for this user.
|
||||||
|
p.mu.RLock()
|
||||||
|
ts, oldUserBlocked := p.invalidatedAll[u.ID]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
if oldUserBlocked && standardClaims.IssuedAt <= ts {
|
||||||
|
return fmt.Errorf("token was invalidated")
|
||||||
|
}
|
||||||
|
//
|
||||||
|
|
||||||
|
return nil // else valid.
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) InvalidateToken(ctx context.Context, standardClaims sso.StandardClaims, u User) error { // fired on SignoutHandler.
|
||||||
|
// invalidate this specific token.
|
||||||
|
p.mu.Lock()
|
||||||
|
p.invalidated[standardClaims.ID] = struct{}{}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) InvalidateTokens(ctx context.Context, u User) error { // fired on SignoutAllHandler.
|
||||||
|
// invalidate all previous tokens came from "u".
|
||||||
|
p.mu.Lock()
|
||||||
|
p.invalidatedAll[u.ID] = time.Now().Unix()
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
30
_examples/mvc/websocket-sso/views/layouts/main.html
Normal file
30
_examples/mvc/websocket-sso/views/layouts/main.html
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ if .Title }}{{ .Title }}{{ else }}Default Main Title{{ end }}</title>
|
||||||
|
</head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<main>{{ template "content" . }}</main>
|
||||||
|
<footer style="position: fixed; bottom: 0; width: 100%;">{{ partial "partials/footer" .}}</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
1
_examples/mvc/websocket-sso/views/partials/footer.html
Normal file
1
_examples/mvc/websocket-sso/views/partials/footer.html
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<i>Iris Web Framework © 2022</i>
|
9
_examples/mvc/websocket-sso/views/signin.html
Normal file
9
_examples/mvc/websocket-sso/views/signin.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<div class="user_signin">
|
||||||
|
<form action="" method="post">
|
||||||
|
<label for="username">Email:</label>
|
||||||
|
<input name="username" type="email" />
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input name="password" type="password" />
|
||||||
|
<input type="submit" value="Sign in" />
|
||||||
|
</form>
|
||||||
|
</div>
|
43
aliases.go
43
aliases.go
|
@ -660,8 +660,41 @@ const (
|
||||||
StatusNetworkReadTimeout = context.StatusNetworkReadTimeout
|
StatusNetworkReadTimeout = context.StatusNetworkReadTimeout
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatusText returns a text for the HTTP status code. It returns the empty
|
var (
|
||||||
// string if the code is unknown.
|
// StatusText returns a text for the HTTP status code. It returns the empty
|
||||||
//
|
// string if the code is unknown.
|
||||||
// Shortcut for core/router#StatusText.
|
//
|
||||||
var StatusText = context.StatusText
|
// Shortcut for core/router#StatusText.
|
||||||
|
StatusText = context.StatusText
|
||||||
|
// RegisterMethods adds custom http methods to the "AllMethods" list.
|
||||||
|
// Use it on initialization of your program.
|
||||||
|
//
|
||||||
|
// Shortcut for core/router#RegisterMethods.
|
||||||
|
RegisterMethods = router.RegisterMethods
|
||||||
|
|
||||||
|
// WebDAVMethods contains a list of WebDAV HTTP Verbs.
|
||||||
|
// Register using RegiterMethods package-level function or
|
||||||
|
// through HandleMany party-level method.
|
||||||
|
WebDAVMethods = []string{
|
||||||
|
MethodGet,
|
||||||
|
MethodHead,
|
||||||
|
MethodPatch,
|
||||||
|
MethodPut,
|
||||||
|
MethodPost,
|
||||||
|
MethodDelete,
|
||||||
|
MethodOptions,
|
||||||
|
MethodConnect,
|
||||||
|
MethodTrace,
|
||||||
|
"MKCOL",
|
||||||
|
"COPY",
|
||||||
|
"MOVE",
|
||||||
|
"LOCK",
|
||||||
|
"UNLOCK",
|
||||||
|
"PROPFIND",
|
||||||
|
"PROPPATCH",
|
||||||
|
"LINK",
|
||||||
|
"UNLINK",
|
||||||
|
"PURGE",
|
||||||
|
"VIEW",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
@ -5314,7 +5314,11 @@ type SecureCookie interface {
|
||||||
//
|
//
|
||||||
// Example: https://github.com/kataras/iris/tree/master/_examples/cookies/securecookie
|
// Example: https://github.com/kataras/iris/tree/master/_examples/cookies/securecookie
|
||||||
func CookieEncoding(encoding SecureCookie, cookieNames ...string) CookieOption {
|
func CookieEncoding(encoding SecureCookie, cookieNames ...string) CookieOption {
|
||||||
return func(_ *Context, c *http.Cookie, op uint8) {
|
if encoding == nil {
|
||||||
|
return func(_ *Context, _ *http.Cookie, _ uint8) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(ctx *Context, c *http.Cookie, op uint8) {
|
||||||
if op == OpCookieDel {
|
if op == OpCookieDel {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -5328,10 +5332,12 @@ func CookieEncoding(encoding SecureCookie, cookieNames ...string) CookieOption {
|
||||||
// Should encode, it's a write to the client operation.
|
// Should encode, it's a write to the client operation.
|
||||||
newVal, err := encoding.Encode(c.Name, c.Value)
|
newVal, err := encoding.Encode(c.Name, c.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ctx.Application().Logger().Error(err)
|
||||||
c.Value = ""
|
c.Value = ""
|
||||||
} else {
|
} else {
|
||||||
c.Value = newVal
|
c.Value = newVal
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
case OpCookieGet:
|
case OpCookieGet:
|
||||||
// Should decode, it's a read from the client operation.
|
// Should decode, it's a read from the client operation.
|
||||||
|
|
|
@ -39,6 +39,13 @@ var AllMethods = []string{
|
||||||
http.MethodTrace,
|
http.MethodTrace,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterMethods adds custom http methods to the "AllMethods" list.
|
||||||
|
// Use it on initialization of your program.
|
||||||
|
func RegisterMethods(newCustomHTTPVerbs ...string) {
|
||||||
|
newMethods := append(AllMethods, newCustomHTTPVerbs...)
|
||||||
|
AllMethods = removeDuplicates(newMethods)
|
||||||
|
}
|
||||||
|
|
||||||
// repository passed to all parties(subrouters), it's the object witch keeps
|
// repository passed to all parties(subrouters), it's the object witch keeps
|
||||||
// all the routes.
|
// all the routes.
|
||||||
type repository struct {
|
type repository struct {
|
||||||
|
|
3
go.mod
3
go.mod
|
@ -18,13 +18,14 @@ require (
|
||||||
github.com/goccy/go-json v0.9.5
|
github.com/goccy/go-json v0.9.5
|
||||||
github.com/golang/snappy v0.0.4
|
github.com/golang/snappy v0.0.4
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
|
github.com/gorilla/securecookie v1.1.1
|
||||||
github.com/iris-contrib/httpexpect/v2 v2.3.1
|
github.com/iris-contrib/httpexpect/v2 v2.3.1
|
||||||
github.com/iris-contrib/jade v1.1.4
|
github.com/iris-contrib/jade v1.1.4
|
||||||
github.com/iris-contrib/schema v0.0.6
|
github.com/iris-contrib/schema v0.0.6
|
||||||
github.com/json-iterator/go v1.1.12
|
github.com/json-iterator/go v1.1.12
|
||||||
github.com/kataras/blocks v0.0.5
|
github.com/kataras/blocks v0.0.5
|
||||||
github.com/kataras/golog v0.1.7
|
github.com/kataras/golog v0.1.7
|
||||||
github.com/kataras/jwt v0.1.5
|
github.com/kataras/jwt v0.1.8
|
||||||
github.com/kataras/neffos v0.0.19
|
github.com/kataras/neffos v0.0.19
|
||||||
github.com/kataras/pio v0.0.10
|
github.com/kataras/pio v0.0.10
|
||||||
github.com/kataras/sitemap v0.0.5
|
github.com/kataras/sitemap v0.0.5
|
||||||
|
|
6
go.sum
generated
6
go.sum
generated
|
@ -85,6 +85,8 @@ github.com/gopherjs/gopherjs v0.0.0-20220221023154-0b2280d3ff96 h1:QJq7UBOuoynsy
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20220221023154-0b2280d3ff96/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
github.com/gopherjs/gopherjs v0.0.0-20220221023154-0b2280d3ff96/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||||
|
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||||
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
@ -109,8 +111,8 @@ github.com/kataras/blocks v0.0.5 h1:jFrsHEDfXZhHTbhkNWgMgpfEQNj1Bwr1IYEYZ9Xxoxg=
|
||||||
github.com/kataras/blocks v0.0.5/go.mod h1:kcJIuvuA8QmGKFLHIZHdCAPCjcE85IhttzXd6W+ayfE=
|
github.com/kataras/blocks v0.0.5/go.mod h1:kcJIuvuA8QmGKFLHIZHdCAPCjcE85IhttzXd6W+ayfE=
|
||||||
github.com/kataras/golog v0.1.7 h1:0TY5tHn5L5DlRIikepcaRR/6oInIr9AiWsxzt0vvlBE=
|
github.com/kataras/golog v0.1.7 h1:0TY5tHn5L5DlRIikepcaRR/6oInIr9AiWsxzt0vvlBE=
|
||||||
github.com/kataras/golog v0.1.7/go.mod h1:jOSQ+C5fUqsNSwurB/oAHq1IFSb0KI3l6GMa7xB6dZA=
|
github.com/kataras/golog v0.1.7/go.mod h1:jOSQ+C5fUqsNSwurB/oAHq1IFSb0KI3l6GMa7xB6dZA=
|
||||||
github.com/kataras/jwt v0.1.5 h1:3UScbsLyo7fsKP6IRPzySH0mcAdTsEu104iWMjGqEyE=
|
github.com/kataras/jwt v0.1.8 h1:u71baOsYD22HWeSOg32tCHbczPjdCk7V4MMeJqTtmGk=
|
||||||
github.com/kataras/jwt v0.1.5/go.mod h1:4ss3aGJi58q3YGmhLUiOvNJnL7UlTXD7+Wf+skgsTmQ=
|
github.com/kataras/jwt v0.1.8/go.mod h1:Q5j2IkcIHnfwy+oNY3TVWuEBJNw0ADgCcXK9CaZwV4o=
|
||||||
github.com/kataras/neffos v0.0.19 h1:j3jp/hzvGFQjnkkLWGNjae5qMSdpMYr66Lxgf8CgcAw=
|
github.com/kataras/neffos v0.0.19 h1:j3jp/hzvGFQjnkkLWGNjae5qMSdpMYr66Lxgf8CgcAw=
|
||||||
github.com/kataras/neffos v0.0.19/go.mod h1:CAAuFqHYX5t0//LLMiVWooOSp5FPeBRD8cn/892P1JE=
|
github.com/kataras/neffos v0.0.19/go.mod h1:CAAuFqHYX5t0//LLMiVWooOSp5FPeBRD8cn/892P1JE=
|
||||||
github.com/kataras/pio v0.0.10 h1:b0qtPUqOpM2O+bqa5wr2O6dN4cQNwSmFd6HQqgVae0g=
|
github.com/kataras/pio v0.0.10 h1:b0qtPUqOpM2O+bqa5wr2O6dN4cQNwSmFd6HQqgVae0g=
|
||||||
|
|
162
sso/configuration.go
Normal file
162
sso/configuration.go
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
//go:build go1.18
|
||||||
|
|
||||||
|
package sso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/securecookie"
|
||||||
|
"github.com/kataras/jwt"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
KIDAccess = "IRIS_SSO_ACCESS"
|
||||||
|
KIDRefresh = "IRIS_SSO_REFRESH"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
Configuration struct {
|
||||||
|
Cookie CookieConfiguration `json:"cookie" yaml:"Cookie" toml:"Cookie" ini:"cookie"`
|
||||||
|
// keep it to always renew the refresh token. RefreshStrategy string `json:"refresh_strategy" yaml:"RefreshStrategy" toml:"RefreshStrategy" ini:"refresh_strategy"`
|
||||||
|
Keys jwt.KeysConfiguration `json:"keys" yaml:"Keys" toml:"Keys" ini:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
CookieConfiguration struct {
|
||||||
|
Name string `json:"cookie" yaml:"Name" toml:"Name" ini:"name"`
|
||||||
|
Hash string `json:"hash" yaml:"Hash" toml:"Hash" ini:"hash"`
|
||||||
|
Block string `json:"block" yaml:"Block" toml:"Block" ini:"block"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Configuration) validate() (jwt.Keys, error) {
|
||||||
|
if c.Cookie.Name != "" {
|
||||||
|
if c.Cookie.Hash == "" || c.Cookie.Block == "" {
|
||||||
|
return nil, fmt.Errorf("cookie block and cookie hash are required for security reasons when cookie is used")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := c.Keys.Load()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sso: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := keys[KIDAccess]; !ok {
|
||||||
|
return nil, fmt.Errorf("sso: %s access token is missing from the configuration", KIDAccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let's keep refresh optional.
|
||||||
|
// if _, ok := keys[KIDRefresh]; !ok {
|
||||||
|
// return nil, fmt.Errorf("sso: %s refresh token is missing from the configuration", KIDRefresh)
|
||||||
|
// }
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindRandom binds the "c" configuration to random values for keys and cookie security.
|
||||||
|
// Keys will not be persisted between restarts,
|
||||||
|
// a more persistent storage should be considered for production applications.
|
||||||
|
func (c *Configuration) BindRandom() error {
|
||||||
|
accessPublic, accessPrivate, err := jwt.GenerateEdDSA()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPublic, refreshPrivate, err := jwt.GenerateEdDSA()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*c = Configuration{
|
||||||
|
Cookie: CookieConfiguration{
|
||||||
|
Name: "iris_sso",
|
||||||
|
Hash: string(securecookie.GenerateRandomKey(64)),
|
||||||
|
Block: string(securecookie.GenerateRandomKey(32)),
|
||||||
|
},
|
||||||
|
Keys: jwt.KeysConfiguration{
|
||||||
|
{
|
||||||
|
ID: KIDAccess,
|
||||||
|
Alg: jwt.EdDSA.Name(),
|
||||||
|
MaxAge: 2 * time.Hour,
|
||||||
|
Public: string(accessPublic),
|
||||||
|
Private: string(accessPrivate),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: KIDRefresh,
|
||||||
|
Alg: jwt.EdDSA.Name(),
|
||||||
|
MaxAge: 720 * time.Hour,
|
||||||
|
Public: string(refreshPublic),
|
||||||
|
Private: string(refreshPrivate),
|
||||||
|
EncryptionKey: string(jwt.MustGenerateRandom(32)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Configuration) BindFile(filename string) error {
|
||||||
|
switch filepath.Ext(filename) {
|
||||||
|
case ".json":
|
||||||
|
contents, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
generatedConfig := MustGenerateConfiguration()
|
||||||
|
if generatedYAML, gErr := generatedConfig.ToJSON(); gErr == nil {
|
||||||
|
err = fmt.Errorf("%w: example:\n\n%s", err, generatedYAML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(contents, c)
|
||||||
|
default:
|
||||||
|
contents, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
generatedConfig := MustGenerateConfiguration()
|
||||||
|
if generatedYAML, gErr := generatedConfig.ToYAML(); gErr == nil {
|
||||||
|
err = fmt.Errorf("%w: example:\n\n%s", err, generatedYAML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return yaml.Unmarshal(contents, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Configuration) ToYAML() ([]byte, error) {
|
||||||
|
return yaml.Marshal(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Configuration) ToJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustGenerateConfiguration() (c Configuration) {
|
||||||
|
if err := c.BindRandom(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfiguration(filename string) (c Configuration, err error) {
|
||||||
|
err = c.BindFile(filename)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustLoadConfiguration(filename string) Configuration {
|
||||||
|
c, err := LoadConfiguration(filename)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
83
sso/provider.go
Normal file
83
sso/provider.go
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
//go:build go1.18
|
||||||
|
|
||||||
|
package sso
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdContext "context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12/context"
|
||||||
|
"github.com/kataras/iris/v12/middleware/jwt"
|
||||||
|
"github.com/kataras/iris/v12/x/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VerifiedToken = jwt.VerifiedToken
|
||||||
|
|
||||||
|
type Provider[T User] interface { // A provider can implement Transformer and ErrorHandler as well.
|
||||||
|
Signin(ctx stdContext.Context, username, password string) (T, error)
|
||||||
|
|
||||||
|
// We could do this instead of transformer below but let's keep separated logic methods:
|
||||||
|
// ValidateToken(ctx context.Context, tok *VerifiedToken, t *T) error
|
||||||
|
ValidateToken(ctx stdContext.Context, standardClaims StandardClaims, t T) error
|
||||||
|
|
||||||
|
InvalidateToken(ctx stdContext.Context, standardClaims StandardClaims, t T) error
|
||||||
|
InvalidateTokens(ctx stdContext.Context, t T) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimsProvider is an optional interface, which may not be used at all.
|
||||||
|
// If completed by a Provider, it signs the jwt token
|
||||||
|
// using these claims to each of the following token types.
|
||||||
|
type ClaimsProvider interface {
|
||||||
|
GetAccessTokenClaims() StandardClaims
|
||||||
|
GetRefreshTokenClaims(accessClaims StandardClaims) StandardClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type Transformer[T User] interface {
|
||||||
|
Transform(ctx stdContext.Context, tok *VerifiedToken) (T, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransformerFunc[T User] func(ctx stdContext.Context, tok *VerifiedToken) (T, error)
|
||||||
|
|
||||||
|
func (fn TransformerFunc[T]) Transform(ctx stdContext.Context, tok *VerifiedToken) (T, error) {
|
||||||
|
return fn(ctx, tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorHandler interface {
|
||||||
|
InvalidArgument(ctx *context.Context, err error)
|
||||||
|
Unauthenticated(ctx *context.Context, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultErrorHandler struct{}
|
||||||
|
|
||||||
|
func (e *DefaultErrorHandler) InvalidArgument(ctx *context.Context, err error) {
|
||||||
|
errors.InvalidArgument.Details(ctx, "unable to parse body", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DefaultErrorHandler) Unauthenticated(ctx *context.Context, err error) {
|
||||||
|
errors.Unauthenticated.Err(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type provider[T User] struct{}
|
||||||
|
|
||||||
|
func newProvider[T User]() *provider[T] {
|
||||||
|
return new(provider[T])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *provider[T]) Signin(ctx stdContext.Context, username, password string) (T, error) { // fired on SigninHandler.
|
||||||
|
// your database...
|
||||||
|
var t T
|
||||||
|
return t, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *provider[T]) ValidateToken(ctx stdContext.Context, standardClaims StandardClaims, t T) error { // fired on VerifyHandler.
|
||||||
|
// your database and checks of blocked tokens...
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *provider[T]) InvalidateToken(ctx stdContext.Context, standardClaims StandardClaims, t T) error { // fired on SignoutHandler.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *provider[T]) InvalidateTokens(ctx stdContext.Context, t T) error { // fired on SignoutAllHandler.
|
||||||
|
return nil
|
||||||
|
}
|
568
sso/sso.go
Normal file
568
sso/sso.go
Normal file
|
@ -0,0 +1,568 @@
|
||||||
|
//go:build go1.18
|
||||||
|
|
||||||
|
package sso
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdContext "context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12/context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/securecookie"
|
||||||
|
"github.com/kataras/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
SSO[T User] struct {
|
||||||
|
config Configuration
|
||||||
|
|
||||||
|
keys jwt.Keys
|
||||||
|
securecookie context.SecureCookie
|
||||||
|
|
||||||
|
providers []Provider[T] // at least one.
|
||||||
|
errorHandler ErrorHandler
|
||||||
|
transformer Transformer[T]
|
||||||
|
claimsProvider ClaimsProvider
|
||||||
|
refreshEnabled bool // if KIDRefresh exists in keys.
|
||||||
|
}
|
||||||
|
|
||||||
|
TVerify[T User] func(t T) error
|
||||||
|
|
||||||
|
SigninRequest struct {
|
||||||
|
Username string `json:"username" form:"username,omitempty"` // username OR email, username has priority over email.
|
||||||
|
Email string `json:"email" form:"email,omitempty"` // email OR username.
|
||||||
|
Password string `json:"password" form:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
SigninResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustLoad[T User](filename string) *SSO[T] {
|
||||||
|
var config Configuration
|
||||||
|
if err := config.BindFile(filename); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := New[T](config)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func Must[T User](s *SSO[T], err error) *SSO[T] {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func New[T User](config Configuration) (*SSO[T], error) {
|
||||||
|
keys, err := config.validate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, refreshEnabled := keys[KIDRefresh]
|
||||||
|
|
||||||
|
s := &SSO[T]{
|
||||||
|
config: config,
|
||||||
|
keys: keys,
|
||||||
|
securecookie: securecookie.New([]byte(config.Cookie.Hash), []byte(config.Cookie.Block)),
|
||||||
|
refreshEnabled: refreshEnabled,
|
||||||
|
// providers: []Provider[T]{newProvider[T]()},
|
||||||
|
errorHandler: new(DefaultErrorHandler),
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) WithProviderAndErrorHandler(provider Provider[T], errHandler ErrorHandler) *SSO[T] {
|
||||||
|
if provider != nil {
|
||||||
|
for i := range s.providers {
|
||||||
|
s.providers[i] = nil
|
||||||
|
}
|
||||||
|
s.providers = nil
|
||||||
|
|
||||||
|
s.providers = make([]Provider[T], 0, 1)
|
||||||
|
s.AddProvider(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errHandler != nil {
|
||||||
|
s.SetErrorHandler(errHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) AddProvider(providers ...Provider[T]) *SSO[T] {
|
||||||
|
// defaultProviderTypename := strings.Replace(fmt.Sprintf("%T", s), "SSO", "provider", 1)
|
||||||
|
// if len(s.providers) == 1 && fmt.Sprintf("%T", s.providers[0]) == defaultProviderTypename {
|
||||||
|
// s.providers = append(s.providers[1:], p...)
|
||||||
|
|
||||||
|
// A provider can also implement both transformer and
|
||||||
|
// error handler if that's the design option of the end-developer.
|
||||||
|
for _, p := range providers {
|
||||||
|
if s.transformer == nil {
|
||||||
|
if transformer, ok := p.(Transformer[T]); ok {
|
||||||
|
s.SetTransformer(transformer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errHandler, ok := p.(ErrorHandler); ok {
|
||||||
|
s.SetErrorHandler(errHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.claimsProvider == nil {
|
||||||
|
if claimsProvider, ok := p.(ClaimsProvider); ok {
|
||||||
|
s.claimsProvider = claimsProvider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.providers = append(s.providers, providers...)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) SetErrorHandler(errHandler ErrorHandler) *SSO[T] {
|
||||||
|
s.errorHandler = errHandler
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) SetTransformer(transformer Transformer[T]) *SSO[T] {
|
||||||
|
s.transformer = transformer
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) SetTransformerFunc(transfermerFunc func(ctx stdContext.Context, tok *VerifiedToken) (T, error)) *SSO[T] {
|
||||||
|
s.transformer = TransformerFunc[T](transfermerFunc)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) Signin(ctx stdContext.Context, username, password string) ([]byte, []byte, error) {
|
||||||
|
var t T
|
||||||
|
|
||||||
|
// get "t" from a valid provider.
|
||||||
|
if n := len(s.providers); n > 0 {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
p := s.providers[i]
|
||||||
|
|
||||||
|
v, err := p.Signin(ctx, username, password)
|
||||||
|
if err != nil {
|
||||||
|
if i == n-1 { // last provider errored.
|
||||||
|
return nil, nil, fmt.Errorf("sso: signin: %w", err)
|
||||||
|
}
|
||||||
|
// keep searching.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// found.
|
||||||
|
t = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, nil, fmt.Errorf("sso: signin: no provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign the tokens.
|
||||||
|
accessToken, refreshToken, err := s.sign(t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("sso: signin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, refreshToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) sign(t T) ([]byte, []byte, error) {
|
||||||
|
// sign the tokens.
|
||||||
|
var (
|
||||||
|
accessStdClaims StandardClaims
|
||||||
|
refreshStdClaims StandardClaims
|
||||||
|
)
|
||||||
|
|
||||||
|
if s.claimsProvider != nil {
|
||||||
|
accessStdClaims = s.claimsProvider.GetAccessTokenClaims()
|
||||||
|
refreshStdClaims = s.claimsProvider.GetRefreshTokenClaims(accessStdClaims)
|
||||||
|
}
|
||||||
|
|
||||||
|
iat := jwt.Clock().Unix()
|
||||||
|
|
||||||
|
if accessStdClaims.IssuedAt == 0 {
|
||||||
|
accessStdClaims.IssuedAt = iat
|
||||||
|
}
|
||||||
|
|
||||||
|
if accessStdClaims.ID == "" {
|
||||||
|
accessStdClaims.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if refreshStdClaims.IssuedAt == 0 {
|
||||||
|
refreshStdClaims.IssuedAt = iat
|
||||||
|
}
|
||||||
|
|
||||||
|
if refreshStdClaims.ID == "" {
|
||||||
|
refreshStdClaims.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if refreshStdClaims.OriginID == "" {
|
||||||
|
// keep a reference of the access token the refresh token is created,
|
||||||
|
// if that access token is invalidated then
|
||||||
|
// its refresh token should be too so the user can force-login.
|
||||||
|
refreshStdClaims.OriginID = accessStdClaims.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := s.keys.SignToken(KIDAccess, t, accessStdClaims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("access: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshToken []byte
|
||||||
|
if s.refreshEnabled {
|
||||||
|
refreshToken, err = s.keys.SignToken(KIDRefresh, t, refreshStdClaims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("refresh: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, refreshToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) SigninHandler(ctx *context.Context) {
|
||||||
|
// No, let the developer decide it based on a middleware, e.g. iris.LimitRequestBodySize.
|
||||||
|
// ctx.SetMaxRequestBodySize(s.maxRequestBodySize)
|
||||||
|
|
||||||
|
var (
|
||||||
|
req SigninRequest
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch ctx.GetContentTypeRequested() {
|
||||||
|
case context.ContentFormHeaderValue, context.ContentFormMultipartHeaderValue:
|
||||||
|
err = ctx.ReadForm(&req)
|
||||||
|
default:
|
||||||
|
err = ctx.ReadJSON(&req)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.errorHandler.InvalidArgument(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" {
|
||||||
|
req.Username = req.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTokenBytes, refreshTokenBytes, err := s.Signin(ctx, req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
s.tryRemoveCookie(ctx) // remove cookie on invalidated.
|
||||||
|
|
||||||
|
s.errorHandler.Unauthenticated(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accessToken := jwt.BytesToString(accessTokenBytes)
|
||||||
|
refreshToken := jwt.BytesToString(refreshTokenBytes)
|
||||||
|
|
||||||
|
s.trySetCookie(ctx, accessToken)
|
||||||
|
|
||||||
|
resp := SigninResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
}
|
||||||
|
ctx.JSON(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) Verify(ctx stdContext.Context, token []byte) (T, StandardClaims, error) {
|
||||||
|
t, claims, err := s.verify(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return t, StandardClaims{}, fmt.Errorf("sso: verify: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) verify(ctx stdContext.Context, token []byte) (T, StandardClaims, error) {
|
||||||
|
var t T
|
||||||
|
|
||||||
|
if len(token) == 0 { // should never happen at this state.
|
||||||
|
return t, StandardClaims{}, jwt.ErrMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
verifiedToken, err := jwt.VerifyWithHeaderValidator(nil, nil, token, s.keys.ValidateHeader, jwt.Leeway(time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
return t, StandardClaims{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.transformer != nil {
|
||||||
|
if t, err = s.transformer.Transform(ctx, verifiedToken); err != nil {
|
||||||
|
return t, StandardClaims{}, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err = verifiedToken.Claims(&t); err != nil {
|
||||||
|
return t, StandardClaims{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
standardClaims := verifiedToken.StandardClaims
|
||||||
|
|
||||||
|
if n := len(s.providers); n > 0 {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
p := s.providers[i]
|
||||||
|
|
||||||
|
err := p.ValidateToken(ctx, standardClaims, t)
|
||||||
|
if err != nil {
|
||||||
|
if i == n-1 { // last provider errored.
|
||||||
|
return t, StandardClaims{}, err
|
||||||
|
}
|
||||||
|
// keep searching.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// token is allowed.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// return t, StandardClaims{}, fmt.Errorf("no provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, standardClaims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Good idea but not practical.
|
||||||
|
func Transform[T User, V User](transformer Transformer[T, V]) context.Handler {
|
||||||
|
return func(ctx *context.Context) {
|
||||||
|
existingUserValue := GetUser[T](ctx)
|
||||||
|
newUserValue, err := transformer.Transform(ctx, existingUserValue)
|
||||||
|
if err != nil {
|
||||||
|
ctx.SetErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Values().Set(userContextKey, newUserValue)
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (s *SSO[T]) VerifyHandler(verifyFuncs ...TVerify[T]) context.Handler {
|
||||||
|
return func(ctx *context.Context) {
|
||||||
|
accessToken := s.extractAccessToken(ctx)
|
||||||
|
|
||||||
|
if accessToken == "" { // if empty, fire 401.
|
||||||
|
s.errorHandler.Unauthenticated(ctx, jwt.ErrMissing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t, claims, err := s.Verify(ctx, []byte(accessToken))
|
||||||
|
if err != nil {
|
||||||
|
s.errorHandler.Unauthenticated(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, verify := range verifyFuncs {
|
||||||
|
if verify == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = verify(t); err != nil {
|
||||||
|
err = fmt.Errorf("sso: verify: %v", err)
|
||||||
|
s.errorHandler.Unauthenticated(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetUser(t)
|
||||||
|
|
||||||
|
// store the user to the request.
|
||||||
|
ctx.Values().Set(accessTokenContextKey, accessToken)
|
||||||
|
|
||||||
|
ctx.Values().Set(userContextKey, t)
|
||||||
|
ctx.Values().Set(standardClaimsContextKey, claims)
|
||||||
|
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) extractAccessToken(ctx *context.Context) string {
|
||||||
|
// first try from authorization: bearer header.
|
||||||
|
accessToken := extractTokenFromHeader(ctx)
|
||||||
|
|
||||||
|
// then if no header, try try extract from cookie.
|
||||||
|
if accessToken == "" {
|
||||||
|
if cookieName := s.config.Cookie.Name; cookieName != "" {
|
||||||
|
accessToken = ctx.GetCookie(cookieName, context.CookieEncoding(s.securecookie))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) Refresh(ctx stdContext.Context, refreshToken []byte) ([]byte, []byte, error) {
|
||||||
|
if !s.refreshEnabled {
|
||||||
|
return nil, nil, fmt.Errorf("sso: refresh: disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
t, _, err := s.verify(ctx, refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("sso: refresh: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh the tokens, both refresh & access tokens will be renew to prevent
|
||||||
|
// malicious 😈 users that may hold a refresh token.
|
||||||
|
accessTok, refreshTok, err := s.sign(t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("sso: refresh: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessTok, refreshTok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) RefreshHandler(ctx *context.Context) {
|
||||||
|
var req RefreshRequest
|
||||||
|
err := ctx.ReadJSON(&req)
|
||||||
|
if err != nil {
|
||||||
|
s.errorHandler.InvalidArgument(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTokenBytes, refreshTokenBytes, err := s.Refresh(ctx, []byte(req.RefreshToken))
|
||||||
|
if err != nil {
|
||||||
|
// s.tryRemoveCookie(ctx)
|
||||||
|
s.errorHandler.Unauthenticated(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := jwt.BytesToString(accessTokenBytes)
|
||||||
|
refreshToken := jwt.BytesToString(refreshTokenBytes)
|
||||||
|
|
||||||
|
s.trySetCookie(ctx, accessToken)
|
||||||
|
|
||||||
|
resp := SigninResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
}
|
||||||
|
ctx.JSON(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) Signout(ctx stdContext.Context, token []byte, all bool) error {
|
||||||
|
t, standardClaims, err := s.verify(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sso: signout: verify: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, n := 0, len(s.providers)-1; i <= n; i++ {
|
||||||
|
p := s.providers[i]
|
||||||
|
|
||||||
|
if all {
|
||||||
|
err = p.InvalidateTokens(ctx, t)
|
||||||
|
} else {
|
||||||
|
err = p.InvalidateToken(ctx, standardClaims, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if i == n { // last provider errored.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// keep trying.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// token is marked as invalidated by a provider.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) SignoutHandler(ctx *context.Context) {
|
||||||
|
s.signoutHandler(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) SignoutAllHandler(ctx *context.Context) {
|
||||||
|
s.signoutHandler(ctx, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) signoutHandler(ctx *context.Context, all bool) {
|
||||||
|
accessToken := s.extractAccessToken(ctx)
|
||||||
|
if accessToken == "" {
|
||||||
|
s.errorHandler.Unauthenticated(ctx, jwt.ErrMissing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Signout(ctx, []byte(accessToken), all)
|
||||||
|
if err != nil {
|
||||||
|
s.errorHandler.Unauthenticated(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.tryRemoveCookie(ctx)
|
||||||
|
|
||||||
|
ctx.SetUser(nil)
|
||||||
|
|
||||||
|
ctx.Values().Remove(accessTokenContextKey)
|
||||||
|
ctx.Values().Remove(userContextKey)
|
||||||
|
ctx.Values().Remove(standardClaimsContextKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var headerKeys = [...]string{
|
||||||
|
"Authorization",
|
||||||
|
"X-Authorization",
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTokenFromHeader(ctx *context.Context) string {
|
||||||
|
for _, headerKey := range headerKeys {
|
||||||
|
headerValue := ctx.GetHeader(headerKey)
|
||||||
|
if headerValue == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// pure check: authorization header format must be Bearer {token}
|
||||||
|
authHeaderParts := strings.Split(headerValue, " ")
|
||||||
|
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return authHeaderParts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) trySetCookie(ctx *context.Context, accessToken string) {
|
||||||
|
if cookieName := s.config.Cookie.Name; cookieName != "" {
|
||||||
|
maxAge := s.keys[KIDAccess].MaxAge
|
||||||
|
if maxAge == 0 {
|
||||||
|
maxAge = context.SetCookieKVExpiration
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Path: "/",
|
||||||
|
Name: cookieName,
|
||||||
|
Value: url.QueryEscape(accessToken),
|
||||||
|
HttpOnly: true,
|
||||||
|
Domain: ctx.Domain(),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: time.Now().Add(maxAge),
|
||||||
|
MaxAge: int(maxAge.Seconds()),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetCookie(cookie, context.CookieEncoding(s.securecookie))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) tryRemoveCookie(ctx *context.Context) {
|
||||||
|
if cookieName := s.config.Cookie.Name; cookieName != "" {
|
||||||
|
ctx.RemoveCookie(cookieName)
|
||||||
|
}
|
||||||
|
}
|
53
sso/user.go
Normal file
53
sso/user.go
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
//go:build go1.18
|
||||||
|
|
||||||
|
package sso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/kataras/iris/v12/context"
|
||||||
|
|
||||||
|
"github.com/kataras/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
StandardClaims = jwt.Claims
|
||||||
|
User = interface{} // any type.
|
||||||
|
)
|
||||||
|
|
||||||
|
const accessTokenContextKey = "iris.sso.context.access_token"
|
||||||
|
|
||||||
|
func GetAccessToken(ctx *context.Context) string {
|
||||||
|
return ctx.Values().GetString(accessTokenContextKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const standardClaimsContextKey = "iris.sso.context.standard_claims"
|
||||||
|
|
||||||
|
func GetStandardClaims(ctx *context.Context) StandardClaims {
|
||||||
|
if v := ctx.Values().Get(standardClaimsContextKey); v != nil {
|
||||||
|
if c, ok := v.(StandardClaims); ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return StandardClaims{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) GetStandardClaims(ctx *context.Context) StandardClaims {
|
||||||
|
return GetStandardClaims(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userContextKey = "iris.sso.context.user"
|
||||||
|
|
||||||
|
func GetUser[T User](ctx *context.Context) T {
|
||||||
|
if v := ctx.Values().Get(userContextKey); v != nil {
|
||||||
|
if t, ok := v.(T); ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var empty T
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSO[T]) GetUser(ctx *context.Context) T {
|
||||||
|
return GetUser[T](ctx)
|
||||||
|
}
|
|
@ -1,15 +1,15 @@
|
||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
stdContext "context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kataras/iris/v12"
|
|
||||||
"github.com/kataras/iris/v12/httptest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var defaultCtx = stdContext.Background()
|
var defaultCtx = context.Background()
|
||||||
|
|
||||||
type testValue struct {
|
type testValue struct {
|
||||||
Firstname string `json:"firstname"`
|
Firstname string `json:"firstname"`
|
||||||
|
@ -18,40 +18,41 @@ type testValue struct {
|
||||||
func TestClientJSON(t *testing.T) {
|
func TestClientJSON(t *testing.T) {
|
||||||
expectedJSON := testValue{Firstname: "Makis"}
|
expectedJSON := testValue{Firstname: "Makis"}
|
||||||
|
|
||||||
app := iris.New()
|
app := http.NewServeMux()
|
||||||
app.Get("/", sendJSON(t, expectedJSON))
|
app.HandleFunc("/send", sendJSON(t, expectedJSON))
|
||||||
|
|
||||||
var irisGotJSON testValue
|
var irisGotJSON testValue
|
||||||
app.Post("/", readJSON(t, &irisGotJSON, &expectedJSON))
|
app.HandleFunc("/read", readJSON(t, &irisGotJSON, &expectedJSON))
|
||||||
|
|
||||||
srv := httptest.NewServer(t, app)
|
srv := httptest.NewServer(app)
|
||||||
client := New(BaseURL(srv.URL))
|
client := New(BaseURL(srv.URL))
|
||||||
|
|
||||||
// Test ReadJSON (read from server).
|
// Test ReadJSON (read from server).
|
||||||
var got testValue
|
var got testValue
|
||||||
if err := client.ReadJSON(defaultCtx, &got, iris.MethodGet, "/", nil); err != nil {
|
if err := client.ReadJSON(defaultCtx, &got, http.MethodGet, "/send", nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test JSON (send to server).
|
// Test JSON (send to server).
|
||||||
resp, err := client.JSON(defaultCtx, iris.MethodPost, "/", expectedJSON)
|
resp, err := client.JSON(defaultCtx, http.MethodPost, "/read", expectedJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
client.DrainResponseBody(resp)
|
client.DrainResponseBody(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendJSON(t *testing.T, v interface{}) iris.Handler {
|
func sendJSON(t *testing.T, v interface{}) http.HandlerFunc {
|
||||||
return func(ctx iris.Context) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, err := ctx.JSON(v); err != nil {
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readJSON(t *testing.T, ptr interface{}, expected interface{}) iris.Handler {
|
func readJSON(t *testing.T, ptr interface{}, expected interface{}) http.HandlerFunc {
|
||||||
return func(ctx iris.Context) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := ctx.ReadJSON(ptr); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(ptr); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,9 @@ package errors
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/kataras/iris/v12"
|
"github.com/kataras/iris/v12/context"
|
||||||
"github.com/kataras/iris/v12/x/client"
|
"github.com/kataras/iris/v12/x/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -13,11 +14,11 @@ import (
|
||||||
//
|
//
|
||||||
// See "OnErrorLog" variable to change the way an error is logged,
|
// See "OnErrorLog" variable to change the way an error is logged,
|
||||||
// by default the error is logged using the Application's Logger's Error method.
|
// by default the error is logged using the Application's Logger's Error method.
|
||||||
type LogErrorFunc = func(ctx iris.Context, err error)
|
type LogErrorFunc = func(ctx *context.Context, err error)
|
||||||
|
|
||||||
// LogError can be modified to customize the way an error is logged to the server (most common: internal server errors, database errors et.c.).
|
// LogError can be modified to customize the way an error is logged to the server (most common: internal server errors, database errors et.c.).
|
||||||
// Can be used to customize the error logging, e.g. using Sentry (cloud-based error console).
|
// Can be used to customize the error logging, e.g. using Sentry (cloud-based error console).
|
||||||
var LogError LogErrorFunc = func(ctx iris.Context, err error) {
|
var LogError LogErrorFunc = func(ctx *context.Context, err error) {
|
||||||
ctx.Application().Logger().Error(err)
|
ctx.Application().Logger().Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,7 +57,7 @@ var errorCodeMap = make(map[ErrorCodeName]ErrorCode)
|
||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
// var (
|
// var (
|
||||||
// NotFound = errors.E("NOT_FOUND", iris.StatusNotFound)
|
// NotFound = errors.E("NOT_FOUND", http.StatusNotFound)
|
||||||
// )
|
// )
|
||||||
// ...
|
// ...
|
||||||
// NotFound.Details(ctx, "resource not found", "user with id: %q was not found", userID)
|
// NotFound.Details(ctx, "resource not found", "user with id: %q was not found", userID)
|
||||||
|
@ -96,57 +97,57 @@ func RegisterErrorCodeMap(errorMap map[ErrorCodeName]int) {
|
||||||
|
|
||||||
// List of default error codes a server should follow and send back to the client.
|
// List of default error codes a server should follow and send back to the client.
|
||||||
var (
|
var (
|
||||||
Cancelled ErrorCodeName = E("CANCELLED", iris.StatusTokenRequired)
|
Cancelled ErrorCodeName = E("CANCELLED", context.StatusTokenRequired)
|
||||||
Unknown ErrorCodeName = E("UNKNOWN", iris.StatusInternalServerError)
|
Unknown ErrorCodeName = E("UNKNOWN", http.StatusInternalServerError)
|
||||||
InvalidArgument ErrorCodeName = E("INVALID_ARGUMENT", iris.StatusBadRequest)
|
InvalidArgument ErrorCodeName = E("INVALID_ARGUMENT", http.StatusBadRequest)
|
||||||
DeadlineExceeded ErrorCodeName = E("DEADLINE_EXCEEDED", iris.StatusGatewayTimeout)
|
DeadlineExceeded ErrorCodeName = E("DEADLINE_EXCEEDED", http.StatusGatewayTimeout)
|
||||||
NotFound ErrorCodeName = E("NOT_FOUND", iris.StatusNotFound)
|
NotFound ErrorCodeName = E("NOT_FOUND", http.StatusNotFound)
|
||||||
AlreadyExists ErrorCodeName = E("ALREADY_EXISTS", iris.StatusConflict)
|
AlreadyExists ErrorCodeName = E("ALREADY_EXISTS", http.StatusConflict)
|
||||||
PermissionDenied ErrorCodeName = E("PERMISSION_DENIED", iris.StatusForbidden)
|
PermissionDenied ErrorCodeName = E("PERMISSION_DENIED", http.StatusForbidden)
|
||||||
Unauthenticated ErrorCodeName = E("UNAUTHENTICATED", iris.StatusUnauthorized)
|
Unauthenticated ErrorCodeName = E("UNAUTHENTICATED", http.StatusUnauthorized)
|
||||||
ResourceExhausted ErrorCodeName = E("RESOURCE_EXHAUSTED", iris.StatusTooManyRequests)
|
ResourceExhausted ErrorCodeName = E("RESOURCE_EXHAUSTED", http.StatusTooManyRequests)
|
||||||
FailedPrecondition ErrorCodeName = E("FAILED_PRECONDITION", iris.StatusBadRequest)
|
FailedPrecondition ErrorCodeName = E("FAILED_PRECONDITION", http.StatusBadRequest)
|
||||||
Aborted ErrorCodeName = E("ABORTED", iris.StatusConflict)
|
Aborted ErrorCodeName = E("ABORTED", http.StatusConflict)
|
||||||
OutOfRange ErrorCodeName = E("OUT_OF_RANGE", iris.StatusBadRequest)
|
OutOfRange ErrorCodeName = E("OUT_OF_RANGE", http.StatusBadRequest)
|
||||||
Unimplemented ErrorCodeName = E("UNIMPLEMENTED", iris.StatusNotImplemented)
|
Unimplemented ErrorCodeName = E("UNIMPLEMENTED", http.StatusNotImplemented)
|
||||||
Internal ErrorCodeName = E("INTERNAL", iris.StatusInternalServerError)
|
Internal ErrorCodeName = E("INTERNAL", http.StatusInternalServerError)
|
||||||
Unavailable ErrorCodeName = E("UNAVAILABLE", iris.StatusServiceUnavailable)
|
Unavailable ErrorCodeName = E("UNAVAILABLE", http.StatusServiceUnavailable)
|
||||||
DataLoss ErrorCodeName = E("DATA_LOSS", iris.StatusInternalServerError)
|
DataLoss ErrorCodeName = E("DATA_LOSS", http.StatusInternalServerError)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message sends an error with a simple message to the client.
|
// Message sends an error with a simple message to the client.
|
||||||
func (e ErrorCodeName) Message(ctx iris.Context, format string, args ...interface{}) {
|
func (e ErrorCodeName) Message(ctx *context.Context, format string, args ...interface{}) {
|
||||||
fail(ctx, e, sprintf(format, args...), "", nil, nil)
|
fail(ctx, e, sprintf(format, args...), "", nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Details sends an error with a message and details to the client.
|
// Details sends an error with a message and details to the client.
|
||||||
func (e ErrorCodeName) Details(ctx iris.Context, msg, details string, detailsArgs ...interface{}) {
|
func (e ErrorCodeName) Details(ctx *context.Context, msg, details string, detailsArgs ...interface{}) {
|
||||||
fail(ctx, e, msg, sprintf(details, detailsArgs...), nil, nil)
|
fail(ctx, e, msg, sprintf(details, detailsArgs...), nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data sends an error with a message and json data to the client.
|
// Data sends an error with a message and json data to the client.
|
||||||
func (e ErrorCodeName) Data(ctx iris.Context, msg string, data interface{}) {
|
func (e ErrorCodeName) Data(ctx *context.Context, msg string, data interface{}) {
|
||||||
fail(ctx, e, msg, "", nil, data)
|
fail(ctx, e, msg, "", nil, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DataWithDetails sends an error with a message, details and json data to the client.
|
// DataWithDetails sends an error with a message, details and json data to the client.
|
||||||
func (e ErrorCodeName) DataWithDetails(ctx iris.Context, msg, details string, data interface{}) {
|
func (e ErrorCodeName) DataWithDetails(ctx *context.Context, msg, details string, data interface{}) {
|
||||||
fail(ctx, e, msg, details, nil, data)
|
fail(ctx, e, msg, details, nil, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation sends an error which renders the invalid fields to the client.
|
// Validation sends an error which renders the invalid fields to the client.
|
||||||
func (e ErrorCodeName) Validation(ctx iris.Context, validationErrors ...ValidationError) {
|
func (e ErrorCodeName) Validation(ctx *context.Context, validationErrors ...ValidationError) {
|
||||||
e.validation(ctx, validationErrors)
|
e.validation(ctx, validationErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e ErrorCodeName) validation(ctx iris.Context, validationErrors interface{}) {
|
func (e ErrorCodeName) validation(ctx *context.Context, validationErrors interface{}) {
|
||||||
fail(ctx, e, "validation failure", "fields were invalid", validationErrors, nil)
|
fail(ctx, e, "validation failure", "fields were invalid", validationErrors, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Err sends the error's text as a message to the client.
|
// Err sends the error's text as a message to the client.
|
||||||
// In exception, if the given "err" is a type of validation error
|
// In exception, if the given "err" is a type of validation error
|
||||||
// then the Validation method is called instead.
|
// then the Validation method is called instead.
|
||||||
func (e ErrorCodeName) Err(ctx iris.Context, err error) {
|
func (e ErrorCodeName) Err(ctx *context.Context, err error) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -163,7 +164,7 @@ func (e ErrorCodeName) Err(ctx iris.Context, err error) {
|
||||||
// error using the "LogError" package-level function, which can be customized.
|
// error using the "LogError" package-level function, which can be customized.
|
||||||
//
|
//
|
||||||
// See "LogErr" too.
|
// See "LogErr" too.
|
||||||
func (e ErrorCodeName) Log(ctx iris.Context, format string, args ...interface{}) {
|
func (e ErrorCodeName) Log(ctx *context.Context, format string, args ...interface{}) {
|
||||||
if SkipCanceled {
|
if SkipCanceled {
|
||||||
if ctx.IsCanceled() {
|
if ctx.IsCanceled() {
|
||||||
return
|
return
|
||||||
|
@ -171,7 +172,7 @@ func (e ErrorCodeName) Log(ctx iris.Context, format string, args ...interface{})
|
||||||
|
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if err, ok := arg.(error); ok {
|
if err, ok := arg.(error); ok {
|
||||||
if iris.IsErrCanceled(err) {
|
if context.IsErrCanceled(err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -184,8 +185,8 @@ func (e ErrorCodeName) Log(ctx iris.Context, format string, args ...interface{})
|
||||||
|
|
||||||
// LogErr sends the given "err" as message to the client and prints that
|
// LogErr sends the given "err" as message to the client and prints that
|
||||||
// error to using the "LogError" package-level function, which can be customized.
|
// error to using the "LogError" package-level function, which can be customized.
|
||||||
func (e ErrorCodeName) LogErr(ctx iris.Context, err error) {
|
func (e ErrorCodeName) LogErr(ctx *context.Context, err error) {
|
||||||
if SkipCanceled && (ctx.IsCanceled() || iris.IsErrCanceled(err)) {
|
if SkipCanceled && (ctx.IsCanceled() || context.IsErrCanceled(err)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,7 +205,7 @@ func (e ErrorCodeName) LogErr(ctx iris.Context, err error) {
|
||||||
// the error will be sent using the "Internal.LogErr" method which sends
|
// the error will be sent using the "Internal.LogErr" method which sends
|
||||||
// HTTP internal server error to the end-client and
|
// HTTP internal server error to the end-client and
|
||||||
// prints the "err" using the "LogError" package-level function.
|
// prints the "err" using the "LogError" package-level function.
|
||||||
func HandleAPIError(ctx iris.Context, err error) {
|
func HandleAPIError(ctx *context.Context, err error) {
|
||||||
// Error expected and came from the external server,
|
// Error expected and came from the external server,
|
||||||
// save its body so we can forward it to the end-client.
|
// save its body so we can forward it to the end-client.
|
||||||
if apiErr, ok := client.GetError(err); ok {
|
if apiErr, ok := client.GetError(err); ok {
|
||||||
|
@ -228,7 +229,7 @@ var (
|
||||||
// The server fails to send an error on two cases:
|
// The server fails to send an error on two cases:
|
||||||
// 1. when the provided error code name is not registered (the error value is the ErrUnexpectedErrorCode)
|
// 1. when the provided error code name is not registered (the error value is the ErrUnexpectedErrorCode)
|
||||||
// 2. when the error contains data but cannot be encoded to json (the value of the error is the result error of json.Marshal).
|
// 2. when the error contains data but cannot be encoded to json (the value of the error is the result error of json.Marshal).
|
||||||
ErrUnexpected = E("UNEXPECTED_ERROR", iris.StatusInternalServerError)
|
ErrUnexpected = E("UNEXPECTED_ERROR", http.StatusInternalServerError)
|
||||||
// ErrUnexpectedErrorCode is the error which logged
|
// ErrUnexpectedErrorCode is the error which logged
|
||||||
// when the given error code name is not registered.
|
// when the given error code name is not registered.
|
||||||
ErrUnexpectedErrorCode = New("unexpected error code name")
|
ErrUnexpectedErrorCode = New("unexpected error code name")
|
||||||
|
@ -261,13 +262,13 @@ func (err Error) Error() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err.ErrorCode.Status <= 0 {
|
if err.ErrorCode.Status <= 0 {
|
||||||
err.ErrorCode.Status = iris.StatusInternalServerError
|
err.ErrorCode.Status = http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
|
||||||
return sprintf("iris http wire error: canonical name: %s, http status code: %d, message: %s, details: %s", err.ErrorCode.CanonicalName, err.ErrorCode.Status, err.Message, err.Details)
|
return sprintf("iris http wire error: canonical name: %s, http status code: %d, message: %s, details: %s", err.ErrorCode.CanonicalName, err.ErrorCode.Status, err.Message, err.Details)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fail(ctx iris.Context, codeName ErrorCodeName, msg, details string, validationErrors interface{}, dataValue interface{}) {
|
func fail(ctx *context.Context, codeName ErrorCodeName, msg, details string, validationErrors interface{}, dataValue interface{}) {
|
||||||
errorCode, ok := errorCodeMap[codeName]
|
errorCode, ok := errorCodeMap[codeName]
|
||||||
if !ok {
|
if !ok {
|
||||||
// This SHOULD NEVER happen, all ErrorCodeNames MUST be registered.
|
// This SHOULD NEVER happen, all ErrorCodeNames MUST be registered.
|
||||||
|
|
Loading…
Reference in New Issue
Block a user