mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
* Add CleanOAuthConfig endpoint We will use this to get the OAuthConfig information, without the client secret, from FeatureBase without having to have access to the config file. This will be useful for the auth-token subcommand. * Add string manipulation utility functions Go doesn't have native support for these kind of things, so I added this to make it easier to do string reversal, and replacing the first string encountered from the end of the string to the front. * Add auth-token subcommand This is for work on [FB-1379](https://molecula.atlassian.net/browse/FB-1379). We need this new auth-token subcommand to allow users to get access and refresh tokens without having to login to featurebase via the UI. This commit adds that functionality. * error on oauth endpoint if auth isn't on * https as default scheme in cmd, not internalclient
429 lines
12 KiB
Go
429 lines
12 KiB
Go
// Copyright 2021 Molecula Corp. All rights reserved.
|
|
|
|
// 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/molecula/featurebase/v3/logger"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
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 = "Access"
|
|
|
|
// ContextValueRefreshToken is the key used to set RefreshTokens in a ctx.
|
|
ContextValueRefreshToken = "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.Now().Sub(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"].(string); ok {
|
|
if expiry, err := strconv.ParseInt(exp, 10, 64); err != nil || 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.Now().Sub(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.Now().Sub(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) 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 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]
|
|
}
|