Add some basic auth

This commit is contained in:
Gabriel Adrian Samfira 2022-04-28 16:13:20 +00:00
parent 66b46ae0ab
commit 0883fcd5cd
24 changed files with 1687 additions and 674 deletions

144
auth/auth.go Normal file
View file

@ -0,0 +1,144 @@
package auth
import (
"context"
"runner-manager/config"
"runner-manager/database/common"
runnerErrors "runner-manager/errors"
"runner-manager/params"
"runner-manager/util"
"time"
"github.com/golang-jwt/jwt"
"github.com/nbutton23/zxcvbn-go"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
func NewAuthenticator(cfg config.JWTAuth, store common.Store) *Authenticator {
return &Authenticator{
cfg: cfg,
store: store,
}
}
type Authenticator struct {
store common.Store
cfg config.JWTAuth
}
func (a *Authenticator) IsInitialized() bool {
info, err := a.store.ControllerInfo()
if err != nil {
return false
}
if info.ControllerID.String() == "" {
return false
}
return true
}
func (a *Authenticator) GetJWTToken(ctx context.Context) (string, error) {
tokenID, err := util.GetRandomString(16)
if err != nil {
return "", errors.Wrap(err, "generating random string")
}
expireToken := time.Now().Add(a.cfg.TimeToLive.Duration()).Unix()
claims := JWTClaims{
StandardClaims: jwt.StandardClaims{
ExpiresAt: expireToken,
// TODO: make this configurable
Issuer: "runner-manager",
},
UserID: UserID(ctx),
TokenID: tokenID,
IsAdmin: IsAdmin(ctx),
FullName: FullName(ctx),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(a.cfg.Secret))
if err != nil {
return "", errors.Wrap(err, "fetching token string")
}
return tokenString, nil
}
func (a *Authenticator) InitController(ctx context.Context, param params.NewUserParams) (params.User, error) {
_, err := a.store.ControllerInfo()
if err != nil {
if !errors.Is(err, runnerErrors.ErrNotFound) {
return params.User{}, errors.Wrap(err, "initializing controller")
}
}
if a.store.HasAdminUser(ctx) {
return params.User{}, runnerErrors.ErrNotFound
}
if param.Email == "" || param.Username == "" {
return params.User{}, runnerErrors.NewBadRequestError("missing username or email")
}
if !util.IsValidEmail(param.Email) {
return params.User{}, runnerErrors.NewBadRequestError("invalid email address")
}
// username is varchar(64)
if len(param.Username) > 64 || !util.IsAlphanumeric(param.Username) {
return params.User{}, runnerErrors.NewBadRequestError("invalid username")
}
param.IsAdmin = true
param.Enabled = true
passwordStenght := zxcvbn.PasswordStrength(param.Password, nil)
if passwordStenght.Score < 4 {
return params.User{}, runnerErrors.NewBadRequestError("password is too weak")
}
hashed, err := util.PaswsordToBcrypt(param.Password)
if err != nil {
return params.User{}, errors.Wrap(err, "creating user")
}
param.Password = hashed
if _, err := a.store.InitController(); err != nil {
return params.User{}, errors.Wrap(err, "initializing controller")
}
return a.store.CreateUser(ctx, param)
}
func (a *Authenticator) AuthenticateUser(ctx context.Context, info params.PasswordLoginParams) (context.Context, error) {
if info.Username == "" {
return ctx, runnerErrors.ErrUnauthorized
}
if info.Password == "" {
return ctx, runnerErrors.ErrUnauthorized
}
user, err := a.store.GetUser(ctx, info.Username)
if err != nil {
if err == runnerErrors.ErrNotFound {
return ctx, runnerErrors.ErrUnauthorized
}
return ctx, errors.Wrap(err, "authenticating")
}
if !user.Enabled {
return ctx, runnerErrors.ErrUnauthorized
}
if user.Password == "" {
return ctx, runnerErrors.ErrUnauthorized
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(info.Password)); err != nil {
return ctx, runnerErrors.ErrUnauthorized
}
return PopulateContext(ctx, user), nil
}

111
auth/context.go Normal file
View file

@ -0,0 +1,111 @@
package auth
import (
"context"
"runner-manager/params"
)
type contextFlags string
const (
isAdminKey contextFlags = "is_admin"
fullNameKey contextFlags = "full_name"
// UserIDFlag is the User ID flag we set in the context
UserIDFlag contextFlags = "user_id"
isEnabledFlag contextFlags = "is_enabled"
jwtTokenFlag contextFlags = "jwt_token"
)
// PopulateContext sets the appropriate fields in the context, based on
// the user object
func PopulateContext(ctx context.Context, user params.User) context.Context {
ctx = SetUserID(ctx, user.ID)
ctx = SetAdmin(ctx, user.IsAdmin)
ctx = SetIsEnabled(ctx, user.Enabled)
ctx = SetFullName(ctx, user.FullName)
return ctx
}
// SetFullName sets the user full name in the context
func SetFullName(ctx context.Context, fullName string) context.Context {
return context.WithValue(ctx, fullNameKey, fullName)
}
// FullName returns the full name from context
func FullName(ctx context.Context) string {
name := ctx.Value(fullNameKey)
if name == nil {
return ""
}
return name.(string)
}
// SetJWTClaim will set the JWT claim in the context
func SetJWTClaim(ctx context.Context, claim JWTClaims) context.Context {
return context.WithValue(ctx, jwtTokenFlag, claim)
}
// JWTClaim returns the JWT claim saved in the context
func JWTClaim(ctx context.Context) JWTClaims {
jwtClaim := ctx.Value(jwtTokenFlag)
if jwtClaim == nil {
return JWTClaims{}
}
return jwtClaim.(JWTClaims)
}
// SetIsEnabled sets a flag indicating if account is enabled
func SetIsEnabled(ctx context.Context, enabled bool) context.Context {
return context.WithValue(ctx, isEnabledFlag, enabled)
}
// IsEnabled returns the a boolean indicating if the enabled flag is
// set and is true or false
func IsEnabled(ctx context.Context) bool {
elem := ctx.Value(isEnabledFlag)
if elem == nil {
return false
}
return elem.(bool)
}
// SetAdmin sets the isAdmin flag on the context
func SetAdmin(ctx context.Context, isAdmin bool) context.Context {
return context.WithValue(ctx, isAdminKey, isAdmin)
}
// IsAdmin returns a boolean indicating whether
// or not the context belongs to a logged in user
// and if that context has the admin flag set
func IsAdmin(ctx context.Context) bool {
elem := ctx.Value(isAdminKey)
if elem == nil {
return false
}
return elem.(bool)
}
// SetUserID sets the userID in the context
func SetUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, UserIDFlag, userID)
}
// UserID returns the userID from the context
func UserID(ctx context.Context) string {
userID := ctx.Value(UserIDFlag)
if userID == nil {
return ""
}
return userID.(string)
}
// GetAdminContext will return an admin context. This can be used internally
// when fetching users.
func GetAdminContext() context.Context {
ctx := context.Background()
ctx = SetUserID(ctx, "")
ctx = SetAdmin(ctx, true)
ctx = SetIsEnabled(ctx, true)
return ctx
}

34
auth/init_required.go Normal file
View file

@ -0,0 +1,34 @@
package auth
import (
"encoding/json"
"net/http"
"runner-manager/apiserver/params"
"runner-manager/database/common"
)
// NewjwtMiddleware returns a populated jwtMiddleware
func NewInitRequiredMiddleware(store common.Store) (Middleware, error) {
return &initRequired{
store: store,
}, nil
}
type initRequired struct {
store common.Store
}
// Middleware implements the middleware interface
func (i *initRequired) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctrlInfo, err := i.store.ControllerInfo()
if err != nil || ctrlInfo.ControllerID.String() == "" {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(params.InitializationRequired)
return
}
ctx := r.Context()
next.ServeHTTP(w, r.WithContext(ctx))
})
}

8
auth/interfaces.go Normal file
View file

@ -0,0 +1,8 @@
package auth
import "net/http"
// Middleware defines an authentication middleware
type Middleware interface {
Middleware(next http.Handler) http.Handler
}

View file

@ -1,9 +1,19 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
apiParams "runner-manager/apiserver/params"
"runner-manager/config"
dbCommon "runner-manager/database/common"
runnerErrors "runner-manager/errors"
"runner-manager/params"
"runner-manager/runner/common"
"time"
"github.com/golang-jwt/jwt"
"github.com/pkg/errors"
@ -21,6 +31,15 @@ type InstanceJWTClaims struct {
jwt.StandardClaims
}
// JWTClaims holds JWT claims
type JWTClaims struct {
UserID string `json:"user"`
TokenID string `json:"token_id"`
FullName string `json:"full_name"`
IsAdmin bool `json:"is_admin"`
jwt.StandardClaims
}
func NewInstanceJWTToken(instance params.Instance, secret, entity string, poolType common.PoolType) (string, error) {
// make TTL configurable?
expireToken := time.Now().Add(3 * time.Hour).Unix()
@ -43,3 +62,97 @@ func NewInstanceJWTToken(instance params.Instance, secret, entity string, poolTy
return tokenString, nil
}
// jwtMiddleware is the authentication middleware
// used with gorilla
type jwtMiddleware struct {
store dbCommon.Store
auth *Authenticator
cfg config.JWTAuth
}
// NewjwtMiddleware returns a populated jwtMiddleware
func NewjwtMiddleware(store dbCommon.Store, cfg config.JWTAuth) (Middleware, error) {
return &jwtMiddleware{
store: store,
cfg: cfg,
}, nil
}
func (amw *jwtMiddleware) claimsToContext(ctx context.Context, claims *JWTClaims) (context.Context, error) {
if claims == nil {
return ctx, runnerErrors.ErrUnauthorized
}
if claims.UserID == "" {
return nil, runnerErrors.ErrUnauthorized
}
userInfo, err := amw.store.GetUser(ctx, claims.UserID)
if err != nil {
return ctx, runnerErrors.ErrUnauthorized
}
ctx = PopulateContext(ctx, userInfo)
return ctx, nil
}
func invalidAuthResponse(w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(
apiParams.APIErrorResponse{
Error: "Authentication failed",
Details: "Invalid authentication token",
})
}
// Middleware implements the middleware interface
func (amw *jwtMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Log error details when authentication fails
ctx := r.Context()
authorizationHeader := r.Header.Get("authorization")
if authorizationHeader == "" {
invalidAuthResponse(w)
return
}
bearerToken := strings.Split(authorizationHeader, " ")
if len(bearerToken) != 2 {
invalidAuthResponse(w)
return
}
claims := &JWTClaims{}
token, err := jwt.ParseWithClaims(bearerToken[1], claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("invalid signing method")
}
return []byte(amw.cfg.Secret), nil
})
if err != nil {
invalidAuthResponse(w)
return
}
if !token.Valid {
invalidAuthResponse(w)
return
}
ctx, err = amw.claimsToContext(ctx, claims)
if err != nil {
invalidAuthResponse(w)
return
}
if !IsEnabled(ctx) {
invalidAuthResponse(w)
return
}
ctx = SetJWTClaim(ctx, *claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}