featurebase/authn/authenticate.go
reesporte 836df379ac add test coverage
for the following auth related packages:
* authn
* http
* server

fix minor bugs, do some cleaning up, etc in `authn/authenticate.go` and `http/handler.go`
2022-01-21 13:57:47 -06:00

288 lines
8.3 KiB
Go

// Copyright 2021 Molecula Corp. All rights reserved.
// Package authn handles authentication
package authn
import (
"bytes"
"encoding/base64"
"encoding/gob"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/golang-jwt/jwt"
"github.com/molecula/featurebase/v3/logger"
"github.com/pkg/errors"
"golang.org/x/oauth2"
)
func init() {
gob.Register([]Group{})
}
// Auth holds state, configuration, and utilities needed for authentication.
type Auth struct {
logger logger.Logger
cookieName string
secretKey []byte
groupEndpoint string
logoutEndpoint string
fbURL string // fbURL is the domain FB is hosted on, used for post logout redirection
oAuthConfig *oauth2.Config
}
// NewAuth instantiates and returns a new Auth struct
func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenURL, groupEndpoint, logout, clientID, clientSecret, secretKey string) (auth *Auth, err error) {
auth = &Auth{
logger: logger,
cookieName: "molecula-chip",
groupEndpoint: groupEndpoint,
logoutEndpoint: logout,
fbURL: url,
oAuthConfig: &oauth2.Config{
RedirectURL: fmt.Sprintf("%s/redirect", url),
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: scopes,
Endpoint: oauth2.Endpoint{
AuthURL: authURL,
TokenURL: tokenURL,
},
},
}
if auth.secretKey, err = decodeHex(secretKey); err != nil {
return nil, errors.Wrap(err, "decoding secret key")
}
return auth, nil
}
func (a Auth) SecretKey() []byte {
return a.secretKey
}
// UserInfo holds the information about the user from the token
type UserInfo struct {
UserID string `json:"userid"`
UserName string `json:"username"`
Groups []Group `json:"groups"`
Expiry time.Time `json:"expiry"`
Token string `json:"token"`
}
// Group holds group information for an authenticated user
type Group struct {
GroupID string `json:"id"`
GroupName string `json:"displayName"`
}
// ToGob64 encodes a []Group to a string, returning string and nil on success (todd's idea)
// it has to be a string bc we're using it in a jwt.MapClaims which needs string-y things
func ToGob64(m []Group) (string, error) {
var b bytes.Buffer
if err := gob.NewEncoder(&b).Encode(m); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(b.Bytes()), nil
}
// FromGob64 converts a previously encoded []Group from a string to a []Group
// it has to be a string bc we're using it in a jwt.MapClaims which needs string-y things
func FromGob64(gobbed string) ([]Group, error) {
m := []Group{}
by, err := base64.StdEncoding.DecodeString(gobbed)
if err != nil {
return nil, err
}
b := bytes.Buffer{}
b.Write(by)
d := gob.NewDecoder(&b)
err = d.Decode(&m)
if err != nil {
return nil, err
}
return m, nil
}
// Groups holds a slice of Group for marshalling from Json
type Groups struct {
Groups []Group `json:"value"`
}
// Authenticate takes in a bearer token `bearer` and returns UserInfo from that token
func (a *Auth) Authenticate(bearer string) (*UserInfo, error) {
// parse the bearer token into a jwt.Token
// this also validates the token, and checks that it's not expired
token, err := jwt.Parse(bearer, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return a.secretKey, nil
})
if token == nil || token.Claims == nil || err != nil || !token.Valid {
return nil, fmt.Errorf("parsing bearer token: %v", err)
}
userInfo := UserInfo{}
claims := token.Claims.(jwt.MapClaims)
userInfo.UserID = claims["oid"].(string)
userInfo.UserName = claims["name"].(string)
userInfo.Token = bearer
g := claims["molecula-idp-groups"].(string)
groups, err := FromGob64(g)
if err != nil {
return nil, errors.Wrap(err, "decoding groups")
}
userInfo.Groups = groups
return &userInfo, nil
}
// Login redirects a user to login to their configured oAuth authorize endpoint
func (a *Auth) Login(w http.ResponseWriter, r *http.Request) {
authURL := a.oAuthConfig.AuthCodeURL(a.oAuthConfig.Endpoint.AuthURL)
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
}
// Logout clears out user cookie and redirects user to IdP's logout endpoint
func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: a.cookieName,
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0),
})
redirect := fmt.Sprintf("%s?post_logout_redirect_uri=%s/", a.logoutEndpoint, a.fbURL)
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
}
// Redirect handles the oAuth /redirect endpoint. It gets user information from
// the identity provider and sets a secure cookie holding the user information
// signed by featurebase.
func (a *Auth) Redirect(w http.ResponseWriter, r *http.Request) {
code := r.FormValue("code")
token, err := a.getToken(r, code)
if err != nil {
a.logger.Warnf("getting token from IdP: %+v", err)
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// enrich token with groups!
g, err := a.getGroups(token.AccessToken)
if err != nil {
a.logger.Warnf("getting groups from IdP: %+v", err)
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// with vitamin G! (for groups)
enrichedTkn, err := a.addGroupMembership(token.AccessToken, g)
if err != nil {
a.logger.Warnf("enriching token with group membership: %+v", err)
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
a.setCookie(w, enrichedTkn, token.Expiry)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
// getToken exhanges authorization code for an oAuth2 token
func (a *Auth) getToken(r *http.Request, code string) (*oauth2.Token, error) {
token, err := a.oAuthConfig.Exchange(r.Context(), code)
if err != nil {
return nil, errors.Wrap(err, "exchanging auth code for token")
}
return token, nil
}
// addGroupMembership is only called in `a.Redirect`. It adds groups to a jwt's
// claims, and signs it using `a.secretKey`.
func (a *Auth) addGroupMembership(token string, g []Group) (string, error) {
// parse token into jwt
unenriched, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
if unenriched == nil || unenriched.Claims == nil || err != nil {
return "", fmt.Errorf("parsing bearer token: %v", err)
}
enriched := jwt.New(jwt.SigningMethodHS256)
enriched.Claims = unenriched.Claims
// parse groups into string format
claims := enriched.Claims.(jwt.MapClaims)
groupString, err := ToGob64(g)
if err != nil {
return "", errors.Wrap(err, "failed to serialize groups")
}
// stick it into jwt claims
claims["molecula-idp-groups"] = groupString
// get stringified and signed jwt
tokenStr, err := enriched.SignedString(a.secretKey)
if err != nil {
return "", errors.Wrap(err, "signing jwt")
}
return tokenStr, nil
}
// getGroups gets the group membership for a given token from configured IdP
func (a *Auth) getGroups(token string) ([]Group, error) {
var groups Groups
req, err := http.NewRequest("GET", a.groupEndpoint, nil)
if err != nil {
return groups.Groups, errors.Wrap(err, "creating new request to group endpoint")
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
response, err := http.DefaultClient.Do(req)
if err != nil {
return groups.Groups, errors.Wrap(err, "getting group membership info")
}
defer response.Body.Close()
rawGroups, err := io.ReadAll(response.Body)
if err != nil {
return groups.Groups, errors.Wrap(err, "failed reading group membership response")
}
if err = json.Unmarshal(rawGroups, &groups); err != nil {
return groups.Groups, errors.Wrap(err, "failed unmarshalling group membership response")
}
return groups.Groups, nil
}
func (a *Auth) setCookie(w http.ResponseWriter, token string, expiry time.Time) error {
http.SetCookie(w, &http.Cookie{
Name: a.cookieName,
Value: token,
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Expires: expiry,
})
return nil
}
func decodeHex(hexstr string) ([]byte, error) {
data, err := hex.DecodeString(hexstr)
if err != nil {
return nil, errors.Wrap(err, "decoding hex string to byte slice")
}
if len(data) != 32 {
return nil, fmt.Errorf("invalid key length")
}
return data, nil
}