Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Auth): (70) Added auth package #77

Merged
merged 18 commits into from
Jan 15, 2024
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ require (
github.com/fission/fission v1.19.0
github.com/fxamacker/cbor/v2 v2.4.0
github.com/go-chi/chi/v5 v5.0.7
github.com/go-jose/go-jose/v3 v3.0.1
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/golang-migrate/migrate/v4 v4.16.2
github.com/google/uuid v1.3.0
github.com/gorilla/schema v1.2.0
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD
github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
Expand All @@ -118,6 +120,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
Expand Down Expand Up @@ -151,6 +155,7 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
Expand Down Expand Up @@ -307,6 +312,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
Expand Down Expand Up @@ -347,6 +353,7 @@ go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
Expand Down
19 changes: 19 additions & 0 deletions pkg/auth/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package auth

import (
"net/http"

"sensorbucket.nl/sensorbucket/internal/web"
)

var (
// Authorization errors
ErrUnauthorized = web.NewError(http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
ErrNoTenantIDFound = web.NewError(http.StatusForbidden, "Forbidden", "FORBIDDEN")
ErrNoPermissions = web.NewError(http.StatusForbidden, "Forbidden", "FORBIDDEN")
ErrPermissionsNotGranted = web.NewError(http.StatusForbidden, "Forbidden", "FORBIDDEN")
ErrNoUserID = web.NewError(http.StatusForbidden, "Forbidden", "FORBIDDEN")

// Request and server errors
ErrAuthHeaderInvalidFormat = web.NewError(http.StatusBadRequest, "Authorization header must be formatted as 'Bearer {token}'", "AUTH_HEADER_INVALID_FORMAT")
)
169 changes: 169 additions & 0 deletions pkg/auth/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package auth

import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"

"github.com/go-jose/go-jose/v3"
"github.com/golang-jwt/jwt"
"sensorbucket.nl/sensorbucket/internal/web"
)

type ctxKey int

type claims struct {
TenantID int64 `json:"tid"`
Permissions []permission `json:"perms"`
UserID int64 `json:"uid"`
Expiration int64 `json:"exp"`
}

func (c *claims) Valid() error {
for _, permission := range c.Permissions {
if permission.Valid() != nil {
return fmt.Errorf("invalid permissions")
}
}
if c.TenantID > 0 && c.UserID > 0 && c.Expiration > time.Now().Unix() {
return nil
}
return fmt.Errorf("claims not valid")
}

type jwksClient interface {
Get() (jose.JSONWebKeySet, error)
}

type jwksHttpClient struct {
issuer string
httpClient http.Client
}

func (c *jwksHttpClient) Get() (jose.JSONWebKeySet, error) {
res, err := c.httpClient.Get(fmt.Sprintf("%s/.well-known/jwks.json", c.issuer))

if err != nil {
return jose.JSONWebKeySet{}, fmt.Errorf("failed to fetch jwks: %w", err)
}
var jwks jose.JSONWebKeySet
if err := json.NewDecoder(res.Body).Decode(&jwks); err != nil {
return jose.JSONWebKeySet{}, fmt.Errorf("failed to decode jwks: %w", err)
}
return jwks, nil
}

type contextBuilder struct {
c context.Context
}

func (cb *contextBuilder) With(key ctxKey, value any) *contextBuilder {
cb.c = context.WithValue(cb.c, key, value)
return cb
}

func (cb *contextBuilder) Finish() context.Context {
return cb.c
}

const (
ctxUserID ctxKey = iota
ctxCurrentTenantID
ctxPermissions
)

func NewJWKSHttpClient(issuer string) *jwksHttpClient {
return &jwksHttpClient{
issuer: issuer,
httpClient: http.Client{},
}
}

func Protect() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, tenantIDPresent := fromRequestContext[[]int64](r.Context(), ctxCurrentTenantID)
_, permissionsPresent := fromRequestContext[[]permission](r.Context(), ctxPermissions)
_, userIDPresent := fromRequestContext[int64](r.Context(), ctxUserID)
if tenantIDPresent && permissionsPresent && userIDPresent {
// All required authentication values are present, allow the request
next.ServeHTTP(w, r)
return
}
web.HTTPError(w, ErrUnauthorized)
})
}
}

// Authentication middleware for checking the validity of any present JWT
// Checks if the JWT is signed using the given secret
// Serves the next HTTP handler if there is no JWT or if the JWT is OK
// Anonymous requests are allowed by this handler
func Authenticate(keyClient jwksClient) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "" {
// Allow anonymous requests
next.ServeHTTP(w, r)
return
}
tokenStr, ok := strings.CutPrefix(auth, "Bearer ")
if !ok {
web.HTTPError(w, ErrAuthHeaderInvalidFormat)
return
}

// Retrieve the JWT and ensure it was signed by us
c := claims{}
token, err := jwt.ParseWithClaims(tokenStr, &c, validateJWTFunc(keyClient))
if err == nil && token.Valid {
// JWT itself is validated, pass it to the actual endpoint for further authorization
// First fill the context with user information
cb := contextBuilder{c: r.Context()}
next.ServeHTTP(w, r.WithContext(
cb.
With(ctxCurrentTenantID, []int64{c.TenantID}).
With(ctxUserID, c.UserID).
With(ctxPermissions, c.Permissions).
Finish()))
return
}
log.Printf("[Error] authentication failed err: %s", err)
web.HTTPError(w, ErrUnauthorized)
JEFFTheDev marked this conversation as resolved.
Show resolved Hide resolved
})
}
}

func validateJWTFunc(jwksClient jwksClient) func(token *jwt.Token) (any, error) {
return func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}

// Retrieve JWKS
jwks, err := jwksClient.Get()
if err != nil {
return nil, fmt.Errorf("failed to retrieve jwks: %w", err)
}

// Look for the key as indicated by the token key id
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("no kid in token")
}
keys := jwks.Key(kid)
if len(keys) == 0 {
return nil, fmt.Errorf("no keys found for token")
}
key := keys[0]
if key.Algorithm != token.Method.Alg() {
return nil, fmt.Errorf("key alg differs from token alg: %s vs %s", key.Algorithm, token.Method.Alg())
}
return key.Public().Key, nil
}
}
Loading