Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions pkg/basic_authentication/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Basic Authentication
Is a reusable package that can be dropped into any draft service as a means of quickly authenticating requests on your api's. It's simple username, and password auth but since it's portable and a simple way to get started. It uses the same primitives as the other authentication methods so if you choose to change your strategy hopefully the upgrade is tenable.

## Integrations
Router: Each project might have a different http router that is being used so a simple interface that any system can implement has been defined. Additionally, an implementation using the [chi router](https://github.com/go-chi/chi) has been added in `chi.go` with the interface in `router.go`.

Finally, the storage layer follows the same pattern a reusable interface with a default implementation using [Blueprint](https://github.com/steady-bytes/draft?tab=readme-ov-file#blueprint)

## How to use
1. Copy, and modify the `html/templates` into your service directory at the root in a template folder `./template`

2. Initialize the basic_authentication with the repo, and router configuration. Once initialized add to your service router, and configure your middleware (optionally if authenticating your endpoints) a middleware function has already been included.

```go
// http setup in the service
func NewHTTPHandler(logger chassis.Logger, controller CourseCreatorController, repoUrl string) HTTPHandler {
// setup the blueprint client, this might be optional depending on your repository layer
client := kvv1Connect.NewKeyValueServiceClient(&http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) {
// If you're also using this client for non-h2c traffic, you may want
// to delegate to tls.Dial if the network isn't TCP or the addr isn't
// in an allowlist.
return net.Dial(network, addr)
},
},
}, repoUrl)

authRepo := ba.NewBlueprintBasicAuthenticationRepository(logger, client)
authController := ba.NewBasicAuthenticationController(authRepo)
authHandler := ba.NewChiBasicAuthenticationHandler(logger, authController)

return &httpHandler{
logger: logger,
controller: controller,
authHandler: authHandler,
}
}

// Implement the chassis.HTTPRegistrar interface for the HTTP handler
func (h *httpHandler) RegisterHTTPHandlers(httpMux *http.ServeMux) {
httpMux.Handle(
"/",
ba.RegisterDefaultAuthRoutes(chi.NewRouter(), h.authHandler),
)
}
```

3. Call middleware in your routes
```go
// don't forget to call the middleware when you need to authenticate a route
authHandler.BasicAuthentication()
```
224 changes: 224 additions & 0 deletions pkg/basic_authentication/basic_authentication.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package basic_authentication

import (
"context"
"errors"
"fmt"
"time"

"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
bav1 "github.com/steady-bytes/draft/api/core/authentication/basic/v1"
"golang.org/x/crypto/bcrypt"
"google.golang.org/protobuf/types/known/timestamppb"
)

// BasicAuthentication is the service interface for a basic authentication system.
// It defines the methods required for logging in and logging out users.
type BasicAuthentication interface {
Register(ctx context.Context, entity *bav1.Entity) (*bav1.Entity, error)
Login(ctx context.Context, entity *bav1.Entity, remember bool) (*bav1.Session, error)
Logout(ctx context.Context, refreshToken string) error
RefreshAuthToken(ctx context.Context, refreshToken string) (*bav1.Session, error)
ValidateToken(ctx context.Context, token string) error
}

var (
ErrUserAlreadyExists = errors.New("user already exists")
)

func NewBasicAuthenticationController(repo BasicAuthenticationRepository) BasicAuthentication {
return &basicAuthenticationController{
repository: repo,
}
}

type basicAuthenticationController struct {
repository BasicAuthenticationRepository
}

func (c *basicAuthenticationController) Register(ctx context.Context, entity *bav1.Entity) (*bav1.Entity, error) {
found, err := c.repository.Get(ctx, bav1.LookupEntityKeys_LOOKUP_ENTITY_KEY_USERNAME, entity.Username)
if err != nil {
if !errors.Is(err, ErrUserNotFound) {
return nil, fmt.Errorf("failed to check if user exists: %w", err)
}
}

if found != nil {
return nil, ErrUserAlreadyExists
}

entity.Password, err = c.hashPassword(entity.Password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}

savedEntity, err := c.repository.SaveEntity(ctx, entity)
if err != nil {
return nil, fmt.Errorf("failed to save user: %w", err)
}

return savedEntity, nil
}

func (c *basicAuthenticationController) Login(ctx context.Context, entity *bav1.Entity, remember bool) (*bav1.Session, error) {
storedEntity, err := c.repository.Get(ctx, bav1.LookupEntityKeys_LOOKUP_ENTITY_KEY_USERNAME, entity.Username)
if err != nil {
return nil, err
}

if err := c.checkPassword(storedEntity.Password, entity.Password); err != nil {
return nil, errors.New("invalid credentials")
}

// Generate JWT token
accessToken, err := c.generateAccessToken(storedEntity.Username)
if err != nil {
return nil, err
}

session := &bav1.Session{
Id: uuid.NewString(),
UserId: entity.Id,
CreatedAt: timestamppb.Now(),
Token: accessToken,
// todo make this configurable
ExpiresAt: timestamppb.New(time.Now().Add(24 * time.Hour)), // Default to 24 hours
}

if remember {
// if remember me is true, create a long-lived session
refreshToken, err := c.generateRefreshToken(storedEntity.Username, session.Id)
if err != nil {
return nil, err
}
session.RefreshToken = refreshToken

if _, err := c.repository.SaveSession(ctx, session, storedEntity.Username); err != nil {
return nil, err
}
}

return session, nil
}

func (c *basicAuthenticationController) Logout(ctx context.Context, refreshToken string) error {
// Parse the JWT token
jwtToken, err := c.parseJWT(refreshToken)
if err != nil {
return err
}

if claims, ok := jwtToken.Claims.(jwt.MapClaims); ok && jwtToken.Valid {
username := claims["username"].(string)
sessionID := claims["session"].(string)
return c.repository.DeleteSession(ctx, &bav1.Session{Id: sessionID}, username)
}

return errors.New("invalid refresh token")
}

func (c *basicAuthenticationController) ValidateToken(ctx context.Context, token string) error {
if _, err := c.parseJWT(token); err != nil {
return err
}

return nil
}

func (c *basicAuthenticationController) RefreshAuthToken(ctx context.Context, refreshToken string) (*bav1.Session, error) {
// Parse the JWT token
jwtToken, err := c.parseJWT(refreshToken)
if err != nil {
return nil, err
}

// Validate the token and extract claims
if claims, ok := jwtToken.Claims.(jwt.MapClaims); ok && jwtToken.Valid {
username := claims["username"].(string)
sessionID := claims["session"].(string)
newAccessToken, err := c.generateAccessToken(username)
if err != nil {
return nil, err
}
return &bav1.Session{
Id: sessionID,
UserId: username,
Token: newAccessToken,
RefreshToken: refreshToken,
CreatedAt: timestamppb.Now(),
ExpiresAt: timestamppb.New(time.Now().Add(24 * time.Minute)), // Default to 24 minutes
}, nil
}

return nil, errors.New("invalid refresh token")
}

////////////
// UTILITIES
////////////

// HashPassword hashes a plain-text password using bcrypt.
func (c *basicAuthenticationController) hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}

// CheckPassword compares a bcrypt hashed password with its possible plaintext equivalent.
func (c *basicAuthenticationController) checkPassword(hashedPassword, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}

// TODO: Read in secret key from environment variable or configuration file
// Replace with your own secret key (keep it safe!)
var jwtSecret = []byte("your-very-secret-key")

func (c *basicAuthenticationController) generateAccessToken(username string) (string, error) {
// Set custom and standard claims
claims := jwt.MapClaims{
"username": username,
"exp": time.Now().Add(24 * time.Minute).Unix(), // Expires in 24 minutes
"iat": time.Now().Unix(), // Issued at
}

// Create the token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

// Sign the token with your secret
signedToken, err := token.SignedString(jwtSecret)
if err != nil {
return "", err
}
return signedToken, nil
}

func (c *basicAuthenticationController) generateRefreshToken(username, sessionID string) (string, error) {
// Set custom and standard claims
claims := jwt.MapClaims{
"username": username,
"session": sessionID,
"exp": time.Now().Add(24 * time.Hour).Unix(), // Expires in 24 hours
"iat": time.Now().Unix(), // Issued at
}

// Create the token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

// Sign the token with your secret
signedToken, err := token.SignedString(jwtSecret)
if err != nil {
return "", err
}
return signedToken, nil
}

func (c *basicAuthenticationController) parseJWT(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
// Validate the signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return jwtSecret, nil
})
}
Loading