mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
322 lines
9.6 KiB
Go
322 lines
9.6 KiB
Go
// Copyright 2021 Molecula Corp. All rights reserved.
|
|
|
|
// Package authn handles authentication
|
|
package authn
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt"
|
|
"github.com/gorilla/securecookie"
|
|
"github.com/molecula/featurebase/v2/logger"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// Auth holds state, configuration, and utilities needed for authentication.
|
|
type Auth struct {
|
|
logger logger.Logger
|
|
cookieName string
|
|
refreshWithin time.Duration
|
|
hashKey []byte
|
|
blockKey []byte
|
|
secure *securecookie.SecureCookie
|
|
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, hashKey, blockKey string) (*Auth, error) {
|
|
auth := &Auth{
|
|
logger: logger,
|
|
cookieName: "molecula-chip",
|
|
refreshWithin: 15 * time.Minute,
|
|
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,
|
|
},
|
|
},
|
|
}
|
|
var err error
|
|
if auth.hashKey, err = decodeHex(hashKey); err != nil {
|
|
return nil, errors.Wrap(err, "decoding hash key")
|
|
}
|
|
|
|
if auth.blockKey, err = decodeHex(blockKey); err != nil {
|
|
return nil, errors.Wrap(err, "decoding block key")
|
|
}
|
|
|
|
auth.secure = securecookie.New(auth.hashKey, auth.blockKey)
|
|
|
|
return auth, nil
|
|
}
|
|
|
|
// AuthContext holds the value of an authenticated user's cookie
|
|
type AuthContext struct {
|
|
UserID string
|
|
UserName string
|
|
GroupMembership []Group
|
|
Token *oauth2.Token
|
|
}
|
|
|
|
// Group holds group information for an authenticated user
|
|
type Group struct {
|
|
UserID string
|
|
GroupID string `json:"id"`
|
|
GroupName string `json:"displayName"`
|
|
}
|
|
|
|
// Groups holds a slice of Group informations for marshalling from Json
|
|
type Groups struct {
|
|
Groups []Group `json:"value"`
|
|
}
|
|
|
|
// UserInfo holds user information for an authenticated user
|
|
type UserInfo struct {
|
|
UserID string `json:"userid"`
|
|
UserName string `json:"username"`
|
|
}
|
|
|
|
// Authenticate reads the authentication cookie from a request, returning the
|
|
// user's group memberships on success. If the cookie is not present or has expired,
|
|
// Authenticate redirects the user to sign in. If the cookie is within the
|
|
// refresh window of expiring, the cookie is refreshed, and the updated group
|
|
// membership is returned.
|
|
func (a *Auth) Authenticate(w http.ResponseWriter, r *http.Request) ([]Group, error) {
|
|
cookie, err := a.readCookie(w, r)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/signin", http.StatusTemporaryRedirect)
|
|
return nil, err
|
|
}
|
|
if cookie.Token.Expiry.Before(time.Now().Add(a.refreshWithin)) {
|
|
err = a.refreshToken(w, cookie)
|
|
if err != nil {
|
|
a.logger.Errorf("refreshing access token: ", err)
|
|
if cookie.Token.Expiry.Before(time.Now()) {
|
|
http.Redirect(w, r, "/signin", http.StatusTemporaryRedirect)
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if len(cookie.GroupMembership) == 0 {
|
|
return nil, errors.New("user is not part of any groups in identity provider")
|
|
}
|
|
return cookie.GroupMembership, 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, a.getEmptyCookie())
|
|
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.
|
|
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: 400", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cv, err := a.newAuthContext(token)
|
|
if err != nil || cv == nil {
|
|
a.logger.Warnf("creating cookie: %+v", err)
|
|
http.Error(w, "Bad Request: 400", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
a.setCookie(w, cv)
|
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
|
}
|
|
|
|
// GetUserInfo reads user's cookie and returns their username and userId
|
|
func (a *Auth) GetUserInfo(w http.ResponseWriter, r *http.Request) *UserInfo {
|
|
var resp UserInfo
|
|
cookie, err := a.readCookie(w, r)
|
|
if err != nil {
|
|
a.logger.Warnf("was not able to read cookie for req: %+v", r)
|
|
return &resp
|
|
}
|
|
|
|
return &UserInfo{
|
|
UserID: cookie.UserID,
|
|
UserName: cookie.UserName,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// newAuthContext parses a jwt `token` and returns relevant information in a cookie value struct
|
|
func (a *Auth) newAuthContext(token *oauth2.Token) (*AuthContext, error) {
|
|
if token == nil {
|
|
return nil, errors.New("baking cookie due to nil token")
|
|
}
|
|
if token.AccessToken == "" {
|
|
return nil, errors.New("no access token provided")
|
|
}
|
|
|
|
// We are using ParseUnverified here because we're using the OAuth2.0 authZ code flow
|
|
// which assumes that the IdP gives good responses. This means that if the IdP is
|
|
// insecure, then we are too. But that's the way OAuth works, unfortunately.
|
|
// Also, we assume the jwt is not tampered with bc we communicate with the IdP over HTTPS only.
|
|
accessParsed, _, err := new(jwt.Parser).ParseUnverified(token.AccessToken, jwt.MapClaims{})
|
|
if accessParsed == nil || accessParsed.Claims == nil || err != nil {
|
|
return nil, errors.Wrap(err, fmt.Sprintf("%v parsing jwt claims from access tokens", accessParsed))
|
|
}
|
|
claims := accessParsed.Claims.(jwt.MapClaims)
|
|
|
|
groups, err := a.getGroupMembership(token)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "getting group membership")
|
|
}
|
|
// not needed at this point in the logic and makes the encoded cookie too large
|
|
token.AccessToken = ""
|
|
return &AuthContext{
|
|
UserID: claims["oid"].(string),
|
|
UserName: claims["name"].(string),
|
|
GroupMembership: groups.Groups,
|
|
Token: token,
|
|
}, nil
|
|
}
|
|
|
|
// getGroupMembership uses a oauth2 token to retrieve group membership information from IdP
|
|
func (a *Auth) getGroupMembership(token *oauth2.Token) (Groups, error) {
|
|
var groups Groups
|
|
|
|
req, err := http.NewRequest("GET", a.groupEndpoint, nil)
|
|
if err != nil {
|
|
return groups, errors.Wrap(err, "creating new request to group endpoint")
|
|
}
|
|
|
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
|
response, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return groups, errors.Wrap(err, "getting group membership info")
|
|
}
|
|
|
|
defer response.Body.Close()
|
|
rawGroups, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return groups, errors.Wrap(err, "failed reading group membership response")
|
|
}
|
|
|
|
if err = json.Unmarshal(rawGroups, &groups); err != nil {
|
|
return groups, errors.Wrap(err, "failed unmarshalling group membership response")
|
|
}
|
|
|
|
return groups, nil
|
|
}
|
|
|
|
// readCookie decodes an encrypted and signed cookie and returns the contained info
|
|
func (a *Auth) readCookie(w http.ResponseWriter, r *http.Request) (*AuthContext, error) {
|
|
cookie, err := r.Cookie(a.cookieName)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "cookie not found")
|
|
}
|
|
|
|
var value AuthContext
|
|
err = a.secure.Decode(a.cookieName, cookie.Value, &value)
|
|
if err != nil {
|
|
http.SetCookie(w, a.getEmptyCookie())
|
|
return nil, errors.Wrap(err, "decoding cookie")
|
|
}
|
|
|
|
return &value, nil
|
|
}
|
|
|
|
func (a *Auth) setCookie(w http.ResponseWriter, cookie *AuthContext) error {
|
|
encoded, err := a.secure.Encode(a.cookieName, cookie)
|
|
if err != nil {
|
|
return errors.Wrap(err, "encoding AuthContext")
|
|
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: a.cookieName,
|
|
Value: encoded,
|
|
Path: "/",
|
|
Secure: true,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Expires: cookie.Token.Expiry,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (a *Auth) refreshToken(w http.ResponseWriter, cookie *AuthContext) error {
|
|
if cookie.Token.RefreshToken == "" {
|
|
return errors.New("no refresh token found, check auth scopes to see if refresh tokens are being provided by your IdP")
|
|
}
|
|
tokenSource := a.oAuthConfig.TokenSource(context.Background(), cookie.Token)
|
|
newToken, err := tokenSource.Token()
|
|
if err != nil {
|
|
return errors.Wrap(err, "refreshing token")
|
|
}
|
|
|
|
if newToken.Expiry != cookie.Token.Expiry {
|
|
cv, err := a.newAuthContext(newToken)
|
|
if err != nil {
|
|
return errors.Wrap(err, "creating cookie value from token")
|
|
}
|
|
|
|
a.setCookie(w, cv)
|
|
}
|
|
|
|
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, errors.Wrap(err, "invalid key length")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (a *Auth) getEmptyCookie() *http.Cookie {
|
|
return &http.Cookie{
|
|
Name: a.cookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
Secure: true,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
}
|
|
|
|
}
|