generated from mikestefanello/pagoda
/
auth.go
252 lines (202 loc) · 7.29 KB
/
auth.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
package services
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/francoganga/pagoda_bun/config"
"github.com/francoganga/pagoda_bun/models"
"github.com/francoganga/pagoda_bun/pkg/context"
"github.com/golang-jwt/jwt"
"github.com/uptrace/bun"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
)
const (
// authSessionName stores the name of the session which contains authentication data
authSessionName = "ua"
// authSessionKeyUserID stores the key used to store the user ID in the session
authSessionKeyUserID = "user_id"
// authSessionKeyAuthenticated stores the key used to store the authentication status in the session
authSessionKeyAuthenticated = "authenticated"
)
// NotAuthenticatedError is an error returned when a user is not authenticated
type NotAuthenticatedError struct{}
// Error implements the error interface.
func (e NotAuthenticatedError) Error() string {
return "user not authenticated"
}
// InvalidPasswordTokenError is an error returned when an invalid token is provided
type InvalidPasswordTokenError struct{}
// Error implements the error interface.
func (e InvalidPasswordTokenError) Error() string {
return "invalid password token"
}
// AuthClient is the client that handles authentication requests
type AuthClient struct {
config *config.Config
bun *bun.DB
}
// NewAuthClient creates a new authentication client
func NewAuthClient(cfg *config.Config, bun *bun.DB) *AuthClient {
return &AuthClient{
config: cfg,
bun: bun,
}
}
// Login logs in a user of a given ID
func (c *AuthClient) Login(ctx echo.Context, userID int) error {
sess, err := session.Get(authSessionName, ctx)
if err != nil {
return err
}
sess.Values[authSessionKeyUserID] = userID
sess.Values[authSessionKeyAuthenticated] = true
return sess.Save(ctx.Request(), ctx.Response())
}
// Logout logs the requesting user out
func (c *AuthClient) Logout(ctx echo.Context) error {
sess, err := session.Get(authSessionName, ctx)
if err != nil {
return err
}
sess.Values[authSessionKeyAuthenticated] = false
return sess.Save(ctx.Request(), ctx.Response())
}
// GetAuthenticatedUserID returns the authenticated user's ID, if the user is logged in
func (c *AuthClient) GetAuthenticatedUserID(ctx echo.Context) (int, error) {
sess, err := session.Get(authSessionName, ctx)
if err != nil {
return 0, err
}
if sess.Values[authSessionKeyAuthenticated] == true {
return sess.Values[authSessionKeyUserID].(int), nil
}
return 0, NotAuthenticatedError{}
}
func (c *AuthClient) GetAuthenticatedUser(ctx echo.Context) (*models.User, error) {
if userID, err := c.GetAuthenticatedUserID(ctx); err == nil {
u := new(models.User)
err := c.bun.NewSelect().
Model(u).
Where("id = ?", userID).
Scan(ctx.Request().Context())
if err != nil {
return nil, err
}
return u, nil
}
return nil, NotAuthenticatedError{}
}
// HashPassword returns a hash of a given password
func (c *AuthClient) HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// CheckPassword check if a given password matches a given hash
func (c *AuthClient) CheckPassword(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
// GeneratePasswordResetToken generates a password reset token for a given user.
// For security purposes, the token itself is not stored in the database but rather
// a hash of the token, exactly how passwords are handled. This method returns both
// the generated token as well as the token entity which only contains the hash.
func (c *AuthClient) GeneratePasswordResetToken(ctx echo.Context, userID int) (string, *models.PasswordToken, error) {
// Generate the token, which is what will go in the URL, but not the database
token, err := c.RandomToken(c.config.App.PasswordToken.Length)
if err != nil {
return "", nil, err
}
// Hash the token, which is what will be stored in the database
hash, err := c.HashPassword(token)
if err != nil {
return "", nil, err
}
// Create and save the password reset token
pt := &models.PasswordToken{
Hash: hash,
UserID: userID,
}
c.bun.NewInsert().
Model(pt).
Exec(ctx.Request().Context())
return token, pt, err
}
// GetValidPasswordToken returns a valid, non-expired password token entity for a given user, token ID and token.
// Since the actual token is not stored in the database for security purposes, if a matching password token entity is
// found a hash of the provided token is compared with the hash stored in the database in order to validate.
func (c *AuthClient) GetValidPasswordToken(ctx echo.Context, userID, tokenID int, token string) (*models.PasswordToken, error) {
// Ensure expired tokens are never returned
expiration := time.Now().Add(-c.config.App.PasswordToken.Expiration)
// Query to find a password token entity that matches the given user and token ID
pt := new(models.PasswordToken)
err := c.bun.NewSelect().
Model(pt).
Where("id = ?", tokenID).
Where("user_id = ?", userID).
Where("created_at >= ?", expiration).
Scan(ctx.Request().Context())
if err == sql.ErrNoRows {
return nil, InvalidPasswordTokenError{}
}
if err == nil {
if err = c.CheckPassword(token, pt.Hash); err == nil {
return pt, nil
}
}
if !context.IsCanceledError(err) {
return nil, err
}
return nil, InvalidPasswordTokenError{}
}
// DeletePasswordTokens deletes all password tokens in the database for a belonging to a given user.
// This should be called after a successful password reset.
func (c *AuthClient) DeletePasswordTokens(ctx echo.Context, userID int) error {
pt := new(models.PasswordToken)
_, err := c.bun.NewDelete().
Model(pt).
Where("user_id = ?", userID).
Exec(ctx.Request().Context())
return err
}
// RandomToken generates a random token string of a given length
func (c *AuthClient) RandomToken(length int) (string, error) {
b := make([]byte, (length/2)+1)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
return token[:length], nil
}
// GenerateEmailVerificationToken generates an email verification token for a given email address using JWT which
// is set to expire based on the duration stored in configuration
func (c *AuthClient) GenerateEmailVerificationToken(email string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"email": email,
"exp": time.Now().Add(c.config.App.EmailVerificationTokenExpiration).Unix(),
})
return token.SignedString([]byte(c.config.App.EncryptionKey))
}
// ValidateEmailVerificationToken validates an email verification token and returns the associated email address if
// the token is valid and has not expired
func (c *AuthClient) ValidateEmailVerificationToken(token string) (string, error) {
t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(c.config.App.EncryptionKey), nil
})
if err != nil {
return "", err
}
if claims, ok := t.Claims.(jwt.MapClaims); ok && t.Valid {
return claims["email"].(string), nil
}
return "", errors.New("invalid or expired token")
}