mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
445 lines
12 KiB
Go
445 lines
12 KiB
Go
// Copyright 2022 Molecula Corp. (DBA FeatureBase).
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
// Package authn handles authentication
|
|
package authn
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/metadata"
|
|
|
|
"github.com/featurebasedb/featurebase/v3/logger"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// AuthContextKey is a unique type to prevent collisions when using context.WithValue()
|
|
type AuthContextKey string
|
|
|
|
const (
|
|
// AccessCookieName is the name of the cookie that holds the access token.
|
|
AccessCookieName = "molecula-chip"
|
|
|
|
// RefreshCookieName is the name of the cookie that holds the refresh token.
|
|
RefreshCookieName = "refresh-molecula-chip"
|
|
|
|
// RefreshHeaderName is the name of the header that holds the refresh token.
|
|
RefreshHeaderName = "X-Molecula-Refresh-Token"
|
|
|
|
// ContextValueAccessToken is the key used to set AccessTokens in a ctx.
|
|
ContextValueAccessToken = AuthContextKey("Access")
|
|
|
|
// ContextValueRefreshToken is the key used to set RefreshTokens in a ctx.
|
|
ContextValueRefreshToken = AuthContextKey("Refresh")
|
|
)
|
|
|
|
// cachedGroups is used to hold groups and when they were last cached
|
|
type cachedGroups struct {
|
|
cacheTime time.Time
|
|
groups []Group
|
|
}
|
|
|
|
// 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"`
|
|
RefreshToken string `json:"refreshtoken"`
|
|
}
|
|
|
|
// Group holds group information for an authenticated user
|
|
type Group struct {
|
|
GroupID string `json:"id"`
|
|
GroupName string `json:"displayName"`
|
|
}
|
|
|
|
// Groups holds a slice of Group for marshalling from JSON
|
|
type Groups struct {
|
|
NextLink string `json:"@odata.nextLink"`
|
|
Groups []Group `json:"value"`
|
|
}
|
|
|
|
// Auth holds state, configuration, and utilities needed for authentication.
|
|
type Auth struct {
|
|
logger logger.Logger
|
|
accessCookieName string
|
|
refreshCookieName string
|
|
secretKey []byte
|
|
groupEndpoint string
|
|
logoutEndpoint string
|
|
fbURL string // fbURL is the domain featurebase is hosted on, used for post logout redirection
|
|
oAuthConfig *oauth2.Config
|
|
cacheTTL time.Duration // cacheTTL is used to determine if a cached item should be refreshed or not
|
|
groupsCache map[string]cachedGroups // groupsCache is a map of accessToken -> group memberships
|
|
lastCacheClean time.Time // last cache clean is the time that the cache was last cleaned
|
|
allowedNetworks []net.IPNet // list of allowed networks for ingest
|
|
}
|
|
|
|
// 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, configuredIPs []string) (auth *Auth, err error) {
|
|
auth = &Auth{
|
|
logger: logger,
|
|
accessCookieName: AccessCookieName,
|
|
refreshCookieName: RefreshCookieName,
|
|
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,
|
|
},
|
|
},
|
|
groupsCache: map[string]cachedGroups{},
|
|
cacheTTL: 10 * time.Minute,
|
|
lastCacheClean: time.Now(),
|
|
}
|
|
|
|
if auth.secretKey, err = decodeHex(secretKey); err != nil {
|
|
return nil, errors.Wrap(err, "decoding secret key")
|
|
}
|
|
|
|
// convert IPs and add them to allowed networks
|
|
err = auth.convertIP(configuredIPs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return auth, nil
|
|
}
|
|
|
|
// CleanOAuthConfig returns a's oauthConfig without the client secret
|
|
func (a Auth) CleanOAuthConfig() oauth2.Config {
|
|
b := *a.oAuthConfig
|
|
b.ClientSecret = ""
|
|
return b
|
|
}
|
|
|
|
// SecretKey is a convenient function to get the SecretKey from an Auth struct
|
|
func (a Auth) SecretKey() []byte {
|
|
return a.secretKey
|
|
}
|
|
|
|
// refreshToken refreshes a given access/refresh token pair
|
|
func (a *Auth) refreshToken(access, refresh string) (string, string, error) {
|
|
resp, err := http.PostForm(a.oAuthConfig.Endpoint.TokenURL,
|
|
url.Values{
|
|
"grant_type": {"refresh_token"},
|
|
"refresh_token": {refresh},
|
|
"client_id": {a.oAuthConfig.ClientID},
|
|
"client_secret": {a.oAuthConfig.ClientSecret},
|
|
},
|
|
)
|
|
if err != nil {
|
|
return "", "", errors.Wrap(err, "refreshing token")
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", "", fmt.Errorf("refreshing token: %s", resp.Status)
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
var t oauth2.Token
|
|
if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
|
|
return "", "", errors.Wrap(err, "decoding refreshed token")
|
|
}
|
|
|
|
// remove the old groups from the groups cache
|
|
delete(a.groupsCache, access)
|
|
|
|
return t.AccessToken, t.RefreshToken, nil
|
|
}
|
|
|
|
// Authenticate takes in a auth token `access` and returns UserInfo from that token
|
|
// it is caller's responsibility to inform the user that the access token has been refreshed
|
|
func (a *Auth) Authenticate(access, refresh string) (*UserInfo, error) {
|
|
// clean up the cache every 30 minutes or so
|
|
if time.Since(a.lastCacheClean) >= 30*time.Minute {
|
|
a.cleanCache()
|
|
}
|
|
|
|
if len(access) == 0 {
|
|
return nil, fmt.Errorf("auth token is empty")
|
|
}
|
|
|
|
// NOTE: we are using ParseUnverified here because the IDP validates the
|
|
// token's signature when we get the user's groups, we just need to make
|
|
// sure it's not expired and is well-formed
|
|
token, _, err := new(jwt.Parser).ParseUnverified(access, &jwt.MapClaims{})
|
|
// well-formed-ness check
|
|
if token == nil || token.Claims == nil || err != nil {
|
|
return nil, fmt.Errorf("parsing auth token: %v", err)
|
|
}
|
|
|
|
claims := *token.Claims.(*jwt.MapClaims)
|
|
|
|
// expiry check
|
|
if exp, ok := claims["exp"]; ok {
|
|
var expiry int64
|
|
switch v := exp.(type) {
|
|
case string:
|
|
expiry, err = strconv.ParseInt(v, 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing exp string: %v", err)
|
|
}
|
|
case float64:
|
|
expiry = int64(v)
|
|
case int64:
|
|
expiry = v
|
|
}
|
|
|
|
if expiry < time.Now().UTC().Unix() {
|
|
access, refresh, err = a.refreshToken(access, refresh)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("token is expired: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
userInfo := UserInfo{
|
|
Token: access,
|
|
RefreshToken: refresh,
|
|
Groups: []Group{},
|
|
}
|
|
|
|
if uid, ok := claims["oid"].(string); ok {
|
|
userInfo.UserID = uid
|
|
}
|
|
if name, ok := claims["name"].(string); ok {
|
|
userInfo.UserName = name
|
|
}
|
|
|
|
if userInfo.Groups, err = a.getGroups(access); err != nil {
|
|
return nil, errors.Wrap(err, "getting groups")
|
|
}
|
|
|
|
return &userInfo, nil
|
|
}
|
|
|
|
// cleanCache removes old items from our cache
|
|
func (a *Auth) cleanCache() {
|
|
for access, tkn := range a.groupsCache {
|
|
// if it's been more than 24 hours since the groups were cached
|
|
if time.Since(tkn.cacheTime) >= 24*time.Hour {
|
|
// remove it from our cache
|
|
delete(a.groupsCache, access)
|
|
}
|
|
}
|
|
a.lastCacheClean = time.Now()
|
|
}
|
|
|
|
// 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 the user's cookie, removes the token from our cache, and
|
|
// redirects user to IdP's logout endpoint
|
|
func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
|
|
// remove the access token from a.groupsCache
|
|
if access, err := r.Cookie(a.accessCookieName); err == nil {
|
|
delete(a.groupsCache, access.Value)
|
|
}
|
|
// clear cookie
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: a.accessCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
Secure: true,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Expires: time.Unix(0, 0),
|
|
})
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: a.refreshCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
Secure: true,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Expires: time.Unix(0, 0),
|
|
})
|
|
|
|
http.Redirect(w, r, fmt.Sprintf("%s?post_logout_redirect_uri=%s/", a.logoutEndpoint, a.fbURL), http.StatusTemporaryRedirect)
|
|
}
|
|
|
|
// Redirect handles the oAuth /redirect endpoint. It gets an access token and
|
|
// returns it to the user in the form of a cookie
|
|
func (a *Auth) Redirect(w http.ResponseWriter, r *http.Request) {
|
|
token, err := a.oAuthConfig.Exchange(r.Context(), r.FormValue("code"), oauth2.AccessTypeOffline)
|
|
if err != nil {
|
|
a.logger.Warnf("getting token from IdP: %+v", err)
|
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
a.SetCookie(w, token.AccessToken, token.RefreshToken, token.Expiry)
|
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
|
}
|
|
|
|
// getGroups gets the group membership for a given token from configured IdP
|
|
func (a *Auth) getGroups(token string) ([]Group, error) {
|
|
var groups Groups
|
|
|
|
gc, ok := a.groupsCache[token]
|
|
if ok && (time.Since(gc.cacheTime) < a.cacheTTL) && len(gc.groups) > 0 {
|
|
return gc.groups, nil
|
|
}
|
|
|
|
nextLink := a.groupEndpoint
|
|
for nextLink != "" {
|
|
req, err := http.NewRequest("GET", nextLink, nil)
|
|
if err != nil {
|
|
return nil, 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 nil, errors.Wrap(err, "getting group membership info")
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("getting group membership info: %s", response.Status)
|
|
}
|
|
|
|
var g Groups
|
|
if err = json.NewDecoder(response.Body).Decode(&g); err != nil {
|
|
return groups.Groups, errors.Wrap(err, "failed unmarshalling group membership response")
|
|
}
|
|
|
|
response.Body.Close()
|
|
groups.Groups = append(groups.Groups, g.Groups...)
|
|
nextLink = g.NextLink
|
|
}
|
|
|
|
if len(groups.Groups) == 0 {
|
|
return nil, fmt.Errorf("no groups found")
|
|
}
|
|
|
|
a.groupsCache[token] = cachedGroups{
|
|
cacheTime: time.Now(),
|
|
groups: groups.Groups,
|
|
}
|
|
return groups.Groups, nil
|
|
}
|
|
|
|
func (a *Auth) SetCookie(w http.ResponseWriter, access, refresh string, expiry time.Time) error {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: a.refreshCookieName,
|
|
Value: refresh,
|
|
Path: "/",
|
|
Secure: true,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Expires: expiry,
|
|
})
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: a.accessCookieName,
|
|
Value: access,
|
|
Path: "/",
|
|
Secure: true,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Expires: expiry,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (a *Auth) SetGRPCMetadata(ctx context.Context, md metadata.MD, access, refresh string) (context.Context, error) {
|
|
mCookies := map[string]string{}
|
|
if c, ok := md["cookie"]; ok {
|
|
for _, cookie := range c {
|
|
name, val := parseCookie(cookie)
|
|
mCookies[name] = val
|
|
}
|
|
}
|
|
|
|
mCookies[a.accessCookieName] = access
|
|
mCookies[a.refreshCookieName] = refresh
|
|
|
|
cookies := []string{}
|
|
for name, val := range mCookies {
|
|
cookies = append(cookies, name+"="+val)
|
|
}
|
|
|
|
md["cookie"] = cookies
|
|
return metadata.NewIncomingContext(ctx, md), grpc.SetHeader(ctx, md)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (a *Auth) convertIP(configuredIPs []string) error {
|
|
sz := len(configuredIPs)
|
|
nets := make([]net.IPNet, sz)
|
|
for i, ip := range configuredIPs {
|
|
// skip empty strings
|
|
if ip == "" {
|
|
sz--
|
|
continue
|
|
}
|
|
// for IPs passed without a subnet, append /32 to only allow 1 IP
|
|
// this step is needed because ParseCIDR method assumes a CIDR address
|
|
if !strings.Contains(ip, "/") {
|
|
ip = ip + "/32"
|
|
}
|
|
_, subnet, err := net.ParseCIDR(ip)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "parsing CIDR for %v", ip)
|
|
}
|
|
nets[i] = *subnet
|
|
}
|
|
a.allowedNetworks = nets[:sz]
|
|
return nil
|
|
}
|
|
|
|
// if IP is in allowed networks, then return true to grant admin permissions
|
|
func (a *Auth) CheckAllowedNetworks(clientIP string) bool {
|
|
clientIP = strings.Split(clientIP, ":")[0]
|
|
convertedIP := net.ParseIP(clientIP)
|
|
for _, network := range a.allowedNetworks {
|
|
if network.Contains(convertedIP) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseCookie(cookie string) (name, data string) {
|
|
vals := strings.Split(cookie, "=")
|
|
if len(vals) == 0 {
|
|
vals = []string{"", ""}
|
|
} else if len(vals) < 2 {
|
|
vals = append(vals, "")
|
|
}
|
|
return vals[0], vals[1]
|
|
}
|