1
0
mirror of https://github.com/kataras/iris.git synced 2026-01-02 17:57:11 +00:00

first release of SSO package and more examples

This commit is contained in:
Gerasimos (Makis) Maropoulos
2022-03-28 14:00:26 +03:00
parent 45d693850b
commit cf36063adf
33 changed files with 1805 additions and 67 deletions

View 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
View 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)
}
}

View 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=

View 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
}

View 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
}

View 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>

View File

@@ -0,0 +1 @@
<i>Iris Web Framework &copy; 2022</i>

View 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>