Skip to content

Commit

Permalink
feat: introduce opaque token strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
shaj13 committed Nov 20, 2022
1 parent 62f85b1 commit 87d98d8
Show file tree
Hide file tree
Showing 6 changed files with 664 additions and 1 deletion.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@ Here are a few bullet point reasons you might like to try it out:
* provides a mechanism to customize strategies, even enables writing a custom strategy

## Strategies
> JWT and oauth2 packages provide early access to advanced or experimental
> JWT, Opaque, and oauth2 packages provide early access to advanced or experimental
> functionality to get community feedback. Their APIs and functionality may be subject to
> breaking changes in future releases.
* [JWT](https://pkg.go.dev/github.com/shaj13/go-guardian/v2/auth/strategies/jwt?tab=doc)
* [Opaque(server-side consistent tokens)](https://pkg.go.dev/github.com/shaj13/go-guardian/v2/auth/strategies/opaque?tab=doc)
* [Oauth2-JWT](https://pkg.go.dev/github.com/shaj13/go-guardian/v2/auth/strategies/oauth2/jwt?tab=doc)
* [Oauth2-Introspection](https://pkg.go.dev/github.com/shaj13/go-guardian/v2/auth/strategies/oauth2/introspection?tab=doc)
* [Oauth2-OpenID-userinfo](https://pkg.go.dev/github.com/shaj13/go-guardian/v2/auth/strategies/oauth2/userinfo?tab=doc)
Expand Down
178 changes: 178 additions & 0 deletions _examples/opaque/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Copyright 2020 The Go-Guardian. All rights reserved.
// Use of this source code is governed by a MIT
// license that can be found in the LICENSE file.

package main

import (
"context"
"encoding/json"
"errors"
"fmt"

"log"
"net/http"
"sync"
"time"

"github.com/gorilla/mux"

"github.com/shaj13/libcache"
_ "github.com/shaj13/libcache/fifo"
_ "github.com/shaj13/libcache/idle"

"github.com/shaj13/go-guardian/v2/auth"
"github.com/shaj13/go-guardian/v2/auth/strategies/basic"
"github.com/shaj13/go-guardian/v2/auth/strategies/opaque"
"github.com/shaj13/go-guardian/v2/auth/strategies/token"
"github.com/shaj13/go-guardian/v2/auth/strategies/union"
)

// Usage:
// curl -k http://127.0.0.1:8080/v1/book/1449311601 -u admin:admin
// curl -k http://127.0.0.1:8080/v1/auth/token -u admin:admin <obtain a token>
// curl -k http://127.0.0.1:8080/v1/auth/token -H "Authorization: Bearer <refresh token>"
// curl -k http://127.0.0.1:8080/v1/book/1449311601 -H "Authorization: Bearer <token>"

func main() {
a := newAuthenticator()
router := mux.NewRouter()
router.HandleFunc("/v1/auth/token", a.middleware(http.HandlerFunc(a.createToken))).Methods("GET")
router.HandleFunc("/v1/book/{id}", a.middleware(http.HandlerFunc(getBookAuthor))).Methods("GET")
log.Println("server started and listening on http://127.0.0.1:8080")
http.ListenAndServe("127.0.0.1:8080", router)
}

func getBookAuthor(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
books := map[string]string{
"1449311601": "Ryan Boyd",
"148425094X": "Yvonne Wilson",
"1484220498": "Prabath Siriwarden",
}
body := fmt.Sprintf("Author: %s \n", books[id])
w.Write([]byte(body))
}

func newAuthenticator() *authenticator {
cache := libcache.FIFO.New(0)
db := &db{m: make(map[string]opaque.Token)}
secret := opaque.StaticSecret([]byte("secret"))

refreshScope := token.NewScope("refresh", "/v1/auth/token", "GET")
refreshOpts := []auth.Option{
opaque.WithExpDuration(time.Hour * 30 * 24),
opaque.WithTokenPrefix("r"),
token.SetScopes(refreshScope),
}

basicStrategy := basic.NewCached(validateUser, cache)
accessStrategy := opaque.New(cache, db, secret)
// Use IDLE to prevent caching one time refresh token.
refreshStrategy := opaque.New(libcache.IDLE.New(0), db, secret, refreshOpts...)
union := union.New(accessStrategy, refreshStrategy, basicStrategy)

return &authenticator{
union: union,
refresh: refreshOpts,
secret: secret,
store: db,
}
}

func validateUser(ctx context.Context, r *http.Request, userName, password string) (auth.Info, error) {
// here connect to db or any other service to fetch user and validate it.
if userName == "admin" && password == "admin" {
return auth.NewDefaultUser("admin", "1", nil, nil), nil
}

return nil, fmt.Errorf("Invalid credentials")
}

type authenticator struct {
union union.Union
secret opaque.SecretsKeeper
store opaque.TokenStore
access []auth.Option
refresh []auth.Option
}

func (a *authenticator) createToken(w http.ResponseWriter, r *http.Request) {
user := auth.User(r)
token.WithNamedScopes(user, "refresh") // limit refresh token usage See newAuthenticator.

accessToken, err := opaque.IssueToken(r.Context(), user, a.store, a.secret, a.access...)
if err != nil {
http.Error(w, err.Error(), 500)
return
}

refreshToken, err := opaque.IssueToken(r.Context(), user, a.store, a.secret, a.refresh...)
if err != nil {
http.Error(w, err.Error(), 500)
return
}

resp := struct {
AccessToken string
RefreshToken string
}{
AccessToken: accessToken,
RefreshToken: refreshToken,
}

buf, _ := json.Marshal(resp)
w.Write(buf)
}

func (a *authenticator) middleware(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("Executing Auth Middleware")
_, user, err := a.union.AuthenticateRequest(r)
if err != nil {
log.Println(err)
code := http.StatusUnauthorized
http.Error(w, http.StatusText(code), code)
return
}
log.Printf("User %s Authenticated\n", user.GetUserName())
r = auth.RequestWithUser(user, r)
next.ServeHTTP(w, r)
})
}

type db struct {
mu sync.Mutex
m map[string]opaque.Token
}

func (db db) Store(_ context.Context, t opaque.Token) error {
db.mu.Lock()
defer db.mu.Unlock()
db.m[t.Signature] = t
return nil
}

func (db db) Lookup(_ context.Context, sig string) (opaque.Token, error) {
db.mu.Lock()
defer db.mu.Unlock()

t, ok := db.m[sig]

if !ok {
return opaque.Token{}, errors.New("db: token not found")
}

if t.Prefix == "r" {
// Refresh token is one time password so remove it
// To prevent user use it again.
delete(db.m, sig)
}

return t, nil
}

func (db db) Revoke(ctx context.Context, sig string) error {
return errors.New("revoke not implemented")
}
202 changes: 202 additions & 0 deletions auth/strategies/opaque/opaque.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Package opaque provides server-side consistent tokens.
//
// It generates tokens in a proprietary format that the
// client cannot access and contain some identifier to
// information in a server's persistent storage.
//
// It uses HMAC with SHA to generate and validate tokens.
package opaque

import (
"context"
"crypto"
"crypto/hmac"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"time"

"github.com/shaj13/go-guardian/v2/auth"
"github.com/shaj13/go-guardian/v2/auth/strategies/token"
)

// SecretsKeeper hold all secrets/keys to sign and parse opaque token.
type SecretsKeeper interface {
// Keys return's keys to sign and parse opaque token,
// The Returned keys must be in descending order timestamp.
Keys() ([][]byte, error)
}

// StaticSecret implements the SecretsKeeper and holds only a single secret.
type StaticSecret []byte

// Keys return's keys to sign and parse opaque token,
func (s StaticSecret) Keys() ([][]byte, error) {
return [][]byte{s}, nil
}

// TokenStore is used to manage client tokens. Tokens are used for
// clients to authenticate, and each token is mapped to an applicable auth info.
type TokenStore interface {
// Store used to store a new token entry.
Store(context.Context, Token) error
// Lookup used to get token entry by its signature.
Lookup(ctx context.Context, signature string) (Token, error)
// Revoke used to delete token entry by its signature.
Revoke(ctx context.Context, signature string) error
}

// Token represent a token entry in token store.
type Token struct {
// Lifespan represent when the token expires.
Lifespan time.Time
// Signature a unique HMAC, per token.
//
// Signature used to verify client token.
//
// Store the signature in plaintext without
// any form of obfuscation or encryption.
Signature string
// Prefix represent token prefix or type.
Prefix string
// Info represent auth info token is mapped to it.
Info auth.Info
}

// IssueToken issue token for the provided user info.
func IssueToken(
ctx context.Context,
info auth.Info,
s TokenStore,
k SecretsKeeper,
opts ...auth.Option,
) (string, error) {
return newOpaque(s, k, opts...).issue(ctx, info)
}

// GetAuthenticateFunc return function to authenticate request using opaque token.
// The returned function typically used with the token strategy.
func GetAuthenticateFunc(s TokenStore, k SecretsKeeper, opts ...auth.Option) token.AuthenticateFunc {
return newOpaque(s, k, opts...).parse
}

// New return strategy authenticate request using opaque token.
//
// New is similar to:
//
// fn := opaque.GetAuthenticateFunc(tokenStore, secretsKeeper, opts...)
// token.New(fn, cache, opts...)
func New(c auth.Cache, s TokenStore, k SecretsKeeper, opts ...auth.Option) auth.Strategy {
fn := GetAuthenticateFunc(s, k, opts...)
return token.New(fn, c, opts...)
}

func newOpaque(s TokenStore, k SecretsKeeper, opts ...auth.Option) *opaque {
o := &opaque{
tokenLength: 24,
prefix: "s",
exp: time.Hour * 24,
keeper: k,
store: s,
h: crypto.SHA512_256,
}

for _, opt := range opts {
opt.Apply(o)
}

return o
}

type opaque struct {
tokenLength int
prefix string
exp time.Duration
keeper SecretsKeeper
store TokenStore
h crypto.Hash
}

func (o *opaque) issue(ctx context.Context, info auth.Info) (string, error) {
id := make([]byte, o.tokenLength)
if _, err := io.ReadFull(rand.Reader, id); err != nil {
return "", err
}

keys, err := o.keeper.Keys()

if len(keys) == 0 || err != nil {
return "", fmt.Errorf("strategies/opaque: no key to sign token %w", err)
}

signature := o.sign(keys[0], id)

t := Token{
Prefix: o.prefix,
Lifespan: time.Now().Add(o.exp),
Info: info,
Signature: base64.RawURLEncoding.EncodeToString(signature),
}

if err := o.store.Store(ctx, t); err != nil {
return "", err
}

mixed := append(id, signature...)
return o.prefix + "." + base64.RawURLEncoding.EncodeToString(mixed), nil
}

func (o *opaque) parse(ctx context.Context, _ *http.Request, token string) (auth.Info, time.Time, error) {
if len(token) <= (len(o.prefix) + o.tokenLength + 1) {
return nil, time.Time{}, errors.New("strategies/opaque: token is too short")
}

if token[:len(o.prefix)] != o.prefix {
return nil, time.Time{}, errors.New("strategies/opaque: invalid token prefix")
}

mixed, err := base64.RawURLEncoding.DecodeString(token[len(o.prefix)+1:])
if err != nil {
return nil, time.Time{}, err
}

keys, err := o.keeper.Keys()
if len(keys) == 0 || err != nil {
return nil, time.Time{}, fmt.Errorf("strategies/opaque: no key to sign token %w", err)
}

id := mixed[:o.tokenLength]
signature := mixed[o.tokenLength:]
ok := false

for _, key := range keys {
mac := o.sign(key, id)
if ok = hmac.Equal(mac, signature); ok {
break
}
}

if !ok {
return nil, time.Time{}, errors.New("strategies/opaque: invalid token signature")
}

t, err := o.store.Lookup(ctx, base64.RawURLEncoding.EncodeToString(signature))
if err != nil {
return nil, time.Time{}, err
}

if t.Lifespan.Before(time.Now()) {
return nil, time.Time{}, errors.New("strategies/opaque: token is expired")
}

return t.Info, t.Lifespan, nil
}

func (o *opaque) sign(key, id []byte) []byte {
hm := hmac.New(o.h.New, key)
_, _ = hm.Write(id)
return hm.Sum(nil)
}
Loading

0 comments on commit 87d98d8

Please sign in to comment.