Skip to content

Commit d0e2b0c

Browse files
authored
Basic authentication package (#79)
A drop in basic authentication package that can be used in draft services to authenticate requests in a draft service. This is an early version, and is not considered 100% stable yet.
1 parent ff88740 commit d0e2b0c

11 files changed

Lines changed: 1136 additions & 0 deletions

File tree

pkg/basic_authentication/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Basic Authentication
2+
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.
3+
4+
## Integrations
5+
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`.
6+
7+
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)
8+
9+
## How to use
10+
1. Copy, and modify the `html/templates` into your service directory at the root in a template folder `./template`
11+
12+
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.
13+
14+
```go
15+
// http setup in the service
16+
func NewHTTPHandler(logger chassis.Logger, controller CourseCreatorController, repoUrl string) HTTPHandler {
17+
// setup the blueprint client, this might be optional depending on your repository layer
18+
client := kvv1Connect.NewKeyValueServiceClient(&http.Client{
19+
Transport: &http2.Transport{
20+
AllowHTTP: true,
21+
DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) {
22+
// If you're also using this client for non-h2c traffic, you may want
23+
// to delegate to tls.Dial if the network isn't TCP or the addr isn't
24+
// in an allowlist.
25+
return net.Dial(network, addr)
26+
},
27+
},
28+
}, repoUrl)
29+
30+
authRepo := ba.NewBlueprintBasicAuthenticationRepository(logger, client)
31+
authController := ba.NewBasicAuthenticationController(authRepo)
32+
authHandler := ba.NewChiBasicAuthenticationHandler(logger, authController)
33+
34+
return &httpHandler{
35+
logger: logger,
36+
controller: controller,
37+
authHandler: authHandler,
38+
}
39+
}
40+
41+
// Implement the chassis.HTTPRegistrar interface for the HTTP handler
42+
func (h *httpHandler) RegisterHTTPHandlers(httpMux *http.ServeMux) {
43+
httpMux.Handle(
44+
"/",
45+
ba.RegisterDefaultAuthRoutes(chi.NewRouter(), h.authHandler),
46+
)
47+
}
48+
```
49+
50+
3. Call middleware in your routes
51+
```go
52+
// don't forget to call the middleware when you need to authenticate a route
53+
authHandler.BasicAuthentication()
54+
```
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package basic_authentication
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"time"
8+
9+
"github.com/golang-jwt/jwt/v5"
10+
"github.com/google/uuid"
11+
bav1 "github.com/steady-bytes/draft/api/core/authentication/basic/v1"
12+
"golang.org/x/crypto/bcrypt"
13+
"google.golang.org/protobuf/types/known/timestamppb"
14+
)
15+
16+
// BasicAuthentication is the service interface for a basic authentication system.
17+
// It defines the methods required for logging in and logging out users.
18+
type BasicAuthentication interface {
19+
Register(ctx context.Context, entity *bav1.Entity) (*bav1.Entity, error)
20+
Login(ctx context.Context, entity *bav1.Entity, remember bool) (*bav1.Session, error)
21+
Logout(ctx context.Context, refreshToken string) error
22+
RefreshAuthToken(ctx context.Context, refreshToken string) (*bav1.Session, error)
23+
ValidateToken(ctx context.Context, token string) error
24+
}
25+
26+
var (
27+
ErrUserAlreadyExists = errors.New("user already exists")
28+
)
29+
30+
func NewBasicAuthenticationController(repo BasicAuthenticationRepository) BasicAuthentication {
31+
return &basicAuthenticationController{
32+
repository: repo,
33+
}
34+
}
35+
36+
type basicAuthenticationController struct {
37+
repository BasicAuthenticationRepository
38+
}
39+
40+
func (c *basicAuthenticationController) Register(ctx context.Context, entity *bav1.Entity) (*bav1.Entity, error) {
41+
found, err := c.repository.Get(ctx, bav1.LookupEntityKeys_LOOKUP_ENTITY_KEY_USERNAME, entity.Username)
42+
if err != nil {
43+
if !errors.Is(err, ErrUserNotFound) {
44+
return nil, fmt.Errorf("failed to check if user exists: %w", err)
45+
}
46+
}
47+
48+
if found != nil {
49+
return nil, ErrUserAlreadyExists
50+
}
51+
52+
entity.Password, err = c.hashPassword(entity.Password)
53+
if err != nil {
54+
return nil, fmt.Errorf("failed to hash password: %w", err)
55+
}
56+
57+
savedEntity, err := c.repository.SaveEntity(ctx, entity)
58+
if err != nil {
59+
return nil, fmt.Errorf("failed to save user: %w", err)
60+
}
61+
62+
return savedEntity, nil
63+
}
64+
65+
func (c *basicAuthenticationController) Login(ctx context.Context, entity *bav1.Entity, remember bool) (*bav1.Session, error) {
66+
storedEntity, err := c.repository.Get(ctx, bav1.LookupEntityKeys_LOOKUP_ENTITY_KEY_USERNAME, entity.Username)
67+
if err != nil {
68+
return nil, err
69+
}
70+
71+
if err := c.checkPassword(storedEntity.Password, entity.Password); err != nil {
72+
return nil, errors.New("invalid credentials")
73+
}
74+
75+
// Generate JWT token
76+
accessToken, err := c.generateAccessToken(storedEntity.Username)
77+
if err != nil {
78+
return nil, err
79+
}
80+
81+
session := &bav1.Session{
82+
Id: uuid.NewString(),
83+
UserId: entity.Id,
84+
CreatedAt: timestamppb.Now(),
85+
Token: accessToken,
86+
// todo make this configurable
87+
ExpiresAt: timestamppb.New(time.Now().Add(24 * time.Hour)), // Default to 24 hours
88+
}
89+
90+
if remember {
91+
// if remember me is true, create a long-lived session
92+
refreshToken, err := c.generateRefreshToken(storedEntity.Username, session.Id)
93+
if err != nil {
94+
return nil, err
95+
}
96+
session.RefreshToken = refreshToken
97+
98+
if _, err := c.repository.SaveSession(ctx, session, storedEntity.Username); err != nil {
99+
return nil, err
100+
}
101+
}
102+
103+
return session, nil
104+
}
105+
106+
func (c *basicAuthenticationController) Logout(ctx context.Context, refreshToken string) error {
107+
// Parse the JWT token
108+
jwtToken, err := c.parseJWT(refreshToken)
109+
if err != nil {
110+
return err
111+
}
112+
113+
if claims, ok := jwtToken.Claims.(jwt.MapClaims); ok && jwtToken.Valid {
114+
username := claims["username"].(string)
115+
sessionID := claims["session"].(string)
116+
return c.repository.DeleteSession(ctx, &bav1.Session{Id: sessionID}, username)
117+
}
118+
119+
return errors.New("invalid refresh token")
120+
}
121+
122+
func (c *basicAuthenticationController) ValidateToken(ctx context.Context, token string) error {
123+
if _, err := c.parseJWT(token); err != nil {
124+
return err
125+
}
126+
127+
return nil
128+
}
129+
130+
func (c *basicAuthenticationController) RefreshAuthToken(ctx context.Context, refreshToken string) (*bav1.Session, error) {
131+
// Parse the JWT token
132+
jwtToken, err := c.parseJWT(refreshToken)
133+
if err != nil {
134+
return nil, err
135+
}
136+
137+
// Validate the token and extract claims
138+
if claims, ok := jwtToken.Claims.(jwt.MapClaims); ok && jwtToken.Valid {
139+
username := claims["username"].(string)
140+
sessionID := claims["session"].(string)
141+
newAccessToken, err := c.generateAccessToken(username)
142+
if err != nil {
143+
return nil, err
144+
}
145+
return &bav1.Session{
146+
Id: sessionID,
147+
UserId: username,
148+
Token: newAccessToken,
149+
RefreshToken: refreshToken,
150+
CreatedAt: timestamppb.Now(),
151+
ExpiresAt: timestamppb.New(time.Now().Add(24 * time.Minute)), // Default to 24 minutes
152+
}, nil
153+
}
154+
155+
return nil, errors.New("invalid refresh token")
156+
}
157+
158+
////////////
159+
// UTILITIES
160+
////////////
161+
162+
// HashPassword hashes a plain-text password using bcrypt.
163+
func (c *basicAuthenticationController) hashPassword(password string) (string, error) {
164+
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
165+
return string(hash), err
166+
}
167+
168+
// CheckPassword compares a bcrypt hashed password with its possible plaintext equivalent.
169+
func (c *basicAuthenticationController) checkPassword(hashedPassword, password string) error {
170+
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
171+
}
172+
173+
// TODO: Read in secret key from environment variable or configuration file
174+
// Replace with your own secret key (keep it safe!)
175+
var jwtSecret = []byte("your-very-secret-key")
176+
177+
func (c *basicAuthenticationController) generateAccessToken(username string) (string, error) {
178+
// Set custom and standard claims
179+
claims := jwt.MapClaims{
180+
"username": username,
181+
"exp": time.Now().Add(24 * time.Minute).Unix(), // Expires in 24 minutes
182+
"iat": time.Now().Unix(), // Issued at
183+
}
184+
185+
// Create the token
186+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
187+
188+
// Sign the token with your secret
189+
signedToken, err := token.SignedString(jwtSecret)
190+
if err != nil {
191+
return "", err
192+
}
193+
return signedToken, nil
194+
}
195+
196+
func (c *basicAuthenticationController) generateRefreshToken(username, sessionID string) (string, error) {
197+
// Set custom and standard claims
198+
claims := jwt.MapClaims{
199+
"username": username,
200+
"session": sessionID,
201+
"exp": time.Now().Add(24 * time.Hour).Unix(), // Expires in 24 hours
202+
"iat": time.Now().Unix(), // Issued at
203+
}
204+
205+
// Create the token
206+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
207+
208+
// Sign the token with your secret
209+
signedToken, err := token.SignedString(jwtSecret)
210+
if err != nil {
211+
return "", err
212+
}
213+
return signedToken, nil
214+
}
215+
216+
func (c *basicAuthenticationController) parseJWT(tokenString string) (*jwt.Token, error) {
217+
return jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
218+
// Validate the signing method
219+
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
220+
return nil, jwt.ErrSignatureInvalid
221+
}
222+
return jwtSecret, nil
223+
})
224+
}

0 commit comments

Comments
 (0)