Skip to content

Commit cf36063

Browse files
committed
first release of SSO package and more examples
1 parent 45d6938 commit cf36063

File tree

33 files changed

+1805
-67
lines changed

33 files changed

+1805
-67
lines changed

NOTICE

+4
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ Revision ID: 5fc50a00491616d5cd0cbce3abd8b699838e25ca
4444
easyjson 8ab5ff9cd8e4e43 https://github.com/mailru/easyjson
4545
2e8b79f6c47d324
4646
a31dd803cf
47+
48+
securecookie e59506cc896acb7 https://github.com/gorilla/securecookie
49+
f7bf732d4fdf5e2
50+
5f7ccd8983
4751
semver 4487282d78122a2 https://github.com/blang/semver
4852
45e413d7515e7c5
4953
16b70c33fd

_examples/README.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
* [Embedded Single Page Application with other routes](file-server/single-page-application/embedded-single-page-application-with-other-routes/main.go)
125125
* [Upload File](file-server/upload-file/main.go)
126126
* [Upload Multiple Files](file-server/upload-files/main.go)
127+
* [WebDAV](file-server/webdav/main.go)
127128
* View
128129
* [Overview](view/overview/main.go)
129130
* [Layout](view/layout)
@@ -212,7 +213,7 @@
212213
* [Basic](i18n/basic)
213214
* [Ttemplates and Functions](i18n/template)
214215
* [Pluralization and Variables](i18n/plurals)
215-
* Authentication, Authorization & Bot Detection
216+
* Authentication, Authorization & Bot Detection
216217
* Basic Authentication
217218
* [Basic](auth/basicauth/basic)
218219
* [Load from a slice of Users](auth/basicauth/users_list)
@@ -225,6 +226,7 @@
225226
* [Blocklist](auth/jwt/blocklist/main.go)
226227
* [Refresh Token](auth/jwt/refresh-token/main.go)
227228
* [Tutorial](auth/jwt/tutorial)
229+
* [SSO](auth/sso) **NEW (GO 1.18 Generics required)**
228230
* [JWT (community edition)](https://github.com/iris-contrib/middleware/tree/v12/jwt/_example/main.go)
229231
* [OAUth2](auth/goth/main.go)
230232
* [Manage Permissions](auth/permissions/main.go)
@@ -277,6 +279,7 @@
277279
* [Authenticated Controller](mvc/authenticated-controller/main.go)
278280
* [Versioned Controller](mvc/versioned-controller/main.go)
279281
* [Websocket Controller](mvc/websocket)
282+
* [Websocket + Authentication (SSO)](mvc/websocket-sso) **NEW (GO 1.18 Generics required)**
280283
* [Register Middleware](mvc/middleware)
281284
* [gRPC](mvc/grpc-compatible)
282285
* [gRPC Bidirectional Stream](mvc/grpc-compatible-bidirectional-stream)

_examples/auth/sso/README.md

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# SSO (Single Sign On)
2+
3+
```sh
4+
$ go run .
5+
```
6+
7+
1. GET/POST: http://localhost:8080/signin
8+
2. GET: http://localhost:8080/member
9+
3. GET: http://localhost:8080/owner
10+
4. POST: http://localhost:8080/refresh
11+
5. GET: http://localhost:8080/signout
12+
6. GET: http://localhost:8080/signout-all

_examples/auth/sso/main.go

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//go:build go1.18
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
8+
"github.com/kataras/iris/v12"
9+
"github.com/kataras/iris/v12/sso"
10+
)
11+
12+
func allowRole(role AccessRole) sso.TVerify[User] {
13+
return func(u User) error {
14+
if !u.Role.Allow(role) {
15+
return fmt.Errorf("invalid role")
16+
}
17+
18+
return nil
19+
}
20+
}
21+
22+
const configFilename = "./sso.yml"
23+
24+
func main() {
25+
app := iris.New()
26+
app.RegisterView(iris.Blocks(iris.Dir("./views"), ".html").
27+
LayoutDir("layouts").
28+
Layout("main"))
29+
30+
/*
31+
// Easiest 1-liner way, load from configuration and initialize a new sso instance:
32+
s := sso.MustLoad[User]("./sso.yml")
33+
// Bind a configuration from file:
34+
var c sso.Configuration
35+
c.BindFile("./sso.yml")
36+
s, err := sso.New[User](c)
37+
// OR create new programmatically configuration:
38+
config := sso.Configuration{
39+
...fields
40+
}
41+
s, err := sso.New[User](config)
42+
// OR generate a new configuration:
43+
config := sso.MustGenerateConfiguration()
44+
s, err := sso.New[User](config)
45+
// OR generate a new config and save it if cannot open the config file.
46+
if _, err := os.Stat(configFilename); err != nil {
47+
generatedConfig := sso.MustGenerateConfiguration()
48+
configContents, err := generatedConfig.ToYAML()
49+
if err != nil {
50+
panic(err)
51+
}
52+
53+
err = os.WriteFile(configFilename, configContents, 0600)
54+
if err != nil {
55+
panic(err)
56+
}
57+
}
58+
*/
59+
60+
// 1. Load configuration from a file.
61+
ssoConfig, err := sso.LoadConfiguration(configFilename)
62+
if err != nil {
63+
panic(err)
64+
}
65+
66+
// 2. Initialize a new sso instance for "User" claims (generics: go1.18 +).
67+
s, err := sso.New[User](ssoConfig)
68+
if err != nil {
69+
panic(err)
70+
}
71+
72+
// 3. Add a custom provider, in our case is just a memory-based one.
73+
s.AddProvider(NewProvider())
74+
// 3.1. Optionally set a custom error handler.
75+
// s.SetErrorHandler(new(sso.DefaultErrorHandler))
76+
77+
app.Get("/signin", renderSigninForm)
78+
// 4. generate token pairs.
79+
app.Post("/signin", s.SigninHandler)
80+
// 5. refresh token pairs.
81+
app.Post("/refresh", s.RefreshHandler)
82+
// 6. calls the provider's InvalidateToken method.
83+
app.Get("/signout", s.SignoutHandler)
84+
// 7. calls the provider's InvalidateTokens method.
85+
app.Get("/signout-all", s.SignoutAllHandler)
86+
87+
// 8.1. allow access for users with "Member" role.
88+
app.Get("/member", s.VerifyHandler(allowRole(Member)), renderMemberPage(s))
89+
// 8.2. allow access for users with "Owner" role.
90+
app.Get("/owner", s.VerifyHandler(allowRole(Owner)), renderOwnerPage(s))
91+
92+
/* Subdomain user verify:
93+
app.Subdomain("owner", s.VerifyHandler(allowRole(Owner))).Get("/", renderOwnerPage(s))
94+
*/
95+
app.Listen(":8080", iris.WithOptimizations) // Setup HTTPS/TLS for production instead.
96+
/* Test subdomain user verify, one way is ingrok,
97+
add the below to the arguments above:
98+
, iris.WithConfiguration(iris.Configuration{
99+
EnableOptmizations: true,
100+
Tunneling: iris.TunnelingConfiguration{
101+
AuthToken: "YOUR_AUTH_TOKEN",
102+
Region: "us",
103+
Tunnels: []tunnel.Tunnel{
104+
{
105+
Name: "Iris SSO (Test)",
106+
Addr: ":8080",
107+
Hostname: "YOUR_DOMAIN",
108+
},
109+
{
110+
Name: "Iris SSO (Test Subdomain)",
111+
Addr: ":8080",
112+
Hostname: "owner.YOUR_DOMAIN",
113+
},
114+
},
115+
},
116+
})*/
117+
}
118+
119+
func renderSigninForm(ctx iris.Context) {
120+
ctx.View("signin", iris.Map{"Title": "Signin Page"})
121+
}
122+
123+
func renderMemberPage(s *sso.SSO[User]) iris.Handler {
124+
return func(ctx iris.Context) {
125+
user := s.GetUser(ctx)
126+
ctx.Writef("Hello member: %s\n", user.Email)
127+
}
128+
}
129+
130+
func renderOwnerPage(s *sso.SSO[User]) iris.Handler {
131+
return func(ctx iris.Context) {
132+
user := s.GetUser(ctx)
133+
ctx.Writef("Hello owner: %s\n", user.Email)
134+
}
135+
}

_examples/auth/sso/sso.yml

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
Cookie: # optional.
2+
Name: "iris_sso"
3+
Hash: "D*G-KaPdSgUkXp2s5v8y/B?E(H+MbQeThWmYq3t6w9z$C&F)J@NcRfUjXn2r4u7x" # length of 64 characters (512-bit).
4+
Block: "VkYp3s6v9y$B&E)H@McQfTjWmZq4t7w!" # length of 32 characters (256-bit).
5+
Keys:
6+
- ID: IRIS_SSO_ACCESS # required.
7+
Alg: EdDSA
8+
MaxAge: 2h # 2 hours lifetime for access tokens.
9+
Private: |+
10+
-----BEGIN PRIVATE KEY-----
11+
MC4CAQAwBQYDK2VwBCIEIFdZWoDdFny5SMnP9Fyfr8bafi/B527EVZh8JJjDTIFO
12+
-----END PRIVATE KEY-----
13+
Public: |+
14+
-----BEGIN PUBLIC KEY-----
15+
MCowBQYDK2VwAyEAzpgjKSr9E032DX+foiOxq1QDsbzjLxagTN+yVpGWZB4=
16+
-----END PUBLIC KEY-----
17+
- ID: IRIS_SSO_REFRESH # optional. Good practise to have it though.
18+
Alg: EdDSA
19+
# 1 month lifetime for refresh tokens,
20+
# after that period the user has to signin again.
21+
MaxAge: 720h
22+
Private: |+
23+
-----BEGIN PRIVATE KEY-----
24+
MC4CAQAwBQYDK2VwBCIEIHJ1aoIjA2sRp5eqGjGR3/UMucrHbBdBv9p8uwfzZ1KZ
25+
-----END PRIVATE KEY-----
26+
Public: |+
27+
-----BEGIN PUBLIC KEY-----
28+
MCowBQYDK2VwAyEAsKKAr+kDtfAqwG7cZdoEAfh9jHt9W8qi9ur5AA1KQAQ=
29+
-----END PUBLIC KEY-----
30+
# Example of setting a binary form of the encryption key for refresh tokens,
31+
# it could be a "string" as well.
32+
EncryptionKey: !!binary stSNLTu91YyihPxzeEOXKwGVMG00CjcC/68G8nMgmqA=

_examples/auth/sso/user.go

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//go:build go1.18
2+
3+
package main
4+
5+
type AccessRole uint16
6+
7+
func (r AccessRole) Is(v AccessRole) bool {
8+
return r&v != 0
9+
}
10+
11+
func (r AccessRole) Allow(v AccessRole) bool {
12+
return r&v >= v
13+
}
14+
15+
const (
16+
InvalidAccessRole AccessRole = 1 << iota
17+
Read
18+
Write
19+
Delete
20+
21+
Owner = Read | Write | Delete
22+
Member = Read | Write
23+
)
24+
25+
type User struct {
26+
ID string `json:"id"`
27+
Email string `json:"email"`
28+
Role AccessRole `json:"role"`
29+
}
30+
31+
func (u User) GetID() string {
32+
return u.ID
33+
}

_examples/auth/sso/user_provider.go

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//go:build go1.18
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"sync"
9+
"time"
10+
11+
"github.com/kataras/iris/v12/sso"
12+
)
13+
14+
type Provider struct {
15+
dataset []User
16+
17+
invalidated map[string]struct{} // key = token. Entry is blocked.
18+
invalidatedAll map[string]int64 // key = user id, value = timestamp. Issued before is consider invalid.
19+
mu sync.RWMutex
20+
}
21+
22+
func NewProvider() *Provider {
23+
return &Provider{
24+
dataset: []User{
25+
{
26+
ID: "id-1",
27+
28+
Role: Owner,
29+
},
30+
{
31+
ID: "id-2",
32+
33+
Role: Member,
34+
},
35+
},
36+
invalidated: make(map[string]struct{}),
37+
invalidatedAll: make(map[string]int64),
38+
}
39+
}
40+
41+
func (p *Provider) Signin(ctx context.Context, username, password string) (User, error) { // fired on SigninHandler.
42+
// your database...
43+
for _, user := range p.dataset {
44+
if user.Email == username {
45+
return user, nil
46+
}
47+
}
48+
49+
return User{}, fmt.Errorf("user not found")
50+
}
51+
52+
func (p *Provider) ValidateToken(ctx context.Context, standardClaims sso.StandardClaims, u User) error { // fired on VerifyHandler.
53+
// your database and checks of blocked tokens...
54+
55+
// check for specific token ids.
56+
p.mu.RLock()
57+
_, tokenBlocked := p.invalidated[standardClaims.ID]
58+
if !tokenBlocked {
59+
// this will disallow refresh tokens with origin jwt token id as the blocked access token as well.
60+
if standardClaims.OriginID != "" {
61+
_, tokenBlocked = p.invalidated[standardClaims.OriginID]
62+
}
63+
}
64+
p.mu.RUnlock()
65+
66+
if tokenBlocked {
67+
return fmt.Errorf("token was invalidated")
68+
}
69+
//
70+
71+
// check all tokens issuet before the "InvalidateToken" method was fired for this user.
72+
p.mu.RLock()
73+
ts, oldUserBlocked := p.invalidatedAll[u.ID]
74+
p.mu.RUnlock()
75+
76+
if oldUserBlocked && standardClaims.IssuedAt <= ts {
77+
return fmt.Errorf("token was invalidated")
78+
}
79+
//
80+
81+
return nil // else valid.
82+
}
83+
84+
func (p *Provider) InvalidateToken(ctx context.Context, standardClaims sso.StandardClaims, u User) error { // fired on SignoutHandler.
85+
// invalidate this specific token.
86+
p.mu.Lock()
87+
p.invalidated[standardClaims.ID] = struct{}{}
88+
p.mu.Unlock()
89+
90+
return nil
91+
}
92+
93+
func (p *Provider) InvalidateTokens(ctx context.Context, u User) error { // fired on SignoutAllHandler.
94+
// invalidate all previous tokens came from "u".
95+
p.mu.Lock()
96+
p.invalidatedAll[u.ID] = time.Now().Unix()
97+
p.mu.Unlock()
98+
99+
return nil
100+
}
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>{{ if .Title }}{{ .Title }}{{ else }}Default Main Title{{ end }}</title>
7+
</head>
8+
<style>
9+
body {
10+
margin: 0;
11+
display: flex;
12+
min-height: 100vh;
13+
flex-direction: column;
14+
}
15+
main {
16+
display: block;
17+
flex: 1 0 auto;
18+
}
19+
.container {
20+
max-width: 500px;
21+
margin: auto;
22+
}
23+
</style>
24+
<body>
25+
<div class="container">
26+
<main>{{ template "content" . }}</main>
27+
<footer style="position: fixed; bottom: 0; width: 100%;">{{ partial "partials/footer" .}}</footer>
28+
</div>
29+
</body>
30+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<i>Iris Web Framework &copy; 2022</i>

0 commit comments

Comments
 (0)