Add some basic auth
This commit is contained in:
parent
66b46ae0ab
commit
0883fcd5cd
24 changed files with 1687 additions and 674 deletions
144
auth/auth.go
Normal file
144
auth/auth.go
Normal 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
111
auth/context.go
Normal 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
34
auth/init_required.go
Normal 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
8
auth/interfaces.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package auth
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Middleware defines an authentication middleware
|
||||
type Middleware interface {
|
||||
Middleware(next http.Handler) http.Handler
|
||||
}
|
||||
115
auth/jwt.go
115
auth/jwt.go
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue