feat: user_rework (todo -> GetAll)

This commit is contained in:
FernandoJVideira committed 2025-06-11 12:30:22 +01:00
1 parent 6aa3870a5c
commit 189534031b
24 files changed
+647 -142

No files matched your search

-35
View File
@@ -1,35 +0,0 @@
# Server Configuration
export ADDR=":8080"
export EXTERNAL_URL="localhost:8080"
export FRONTEND_URL="http://localhost:5173"
export ENV="development"
export ALLOWED_ORIGINS="https://*,http://*" # TODO: Change to the actual allowed origins on production
# Database Configuration
export DB_ADDR="postgres://postgres:postgres@localhost:5432/lk_tmpl?sslmode=disable"
export DB_MAX_OPEN_CONNS="30"
export DB_MAX_IDLE_CONNS="30"
export DB_MAX_IDLE_TIME="15m"
# Redis Configuration
export REDIS_ADDR="localhost:6379"
export REDIS_PASSWORD=""
export REDIS_DB="0"
export REDIS_ENABLED="false"
# Authentication
export BASIC_AUTH_USERNAME="admin"
export BASIC_AUTH_PASSWORD="admin"
export AUTH_TOKEN_SECRET="your-secret-key-here"
export AUTH_TOKEN_EXP="24h"
export AUTH_TOKEN_ISS="lkapi"
export AUTH_TOKEN_AUD="lkapi"
# Email Configuration
export FROM_EMAIL="[email protected]"
export SENDGRID_API_KEY="your-sendgrid-api-key"
# Rate Limiter
export RATE_LIMITER_REQS_COUNT="20"
export RATE_LIMITER_TIME_FRAME="1m"
export RATE_LIMITER_ENABLED="true"
+1 -1
View File
@@ -1 +1 @@
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit stLine truncated
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit stLine truncated
BIN
View File
Binary file not shown.
+35 -17
View File
@@ -48,9 +48,10 @@ type config struct {
}
type mailConfig struct {
exp time.Duration
fromEmail string
sendGrid sendGridConfig
inviteExp time.Duration
passwordRefreshExp time.Duration
fromEmail string
sendGrid sendGridConfig
}
type authConfig struct {
@@ -64,10 +65,11 @@ type basicConfig struct {
}
type tokenConfig struct {
secret string
exp time.Duration
iss string
aud string
secret string
exp time.Duration
refreshExp time.Duration
iss string
aud string
}
type redisConfig struct {
@@ -89,6 +91,8 @@ type dbConfig struct {
maxIdleTime time.Duration
}
// This `mount` function is responsible for setting up the routing for your API
// using the Chi router. Here's a breakdown of what it does:
func (api *api) mount() http.Handler {
r := chi.NewRouter()
// *CORS
@@ -135,29 +139,37 @@ func (api *api) mount() http.Handler {
r.Route("/users", func(r chi.Router) {
r.Put("/activate/{token}", api.activateUserHandler)
//r.Put("/reset-password/{token}", api.resetPasswordHandler)
r.Route("/{id}", func(r chi.Router) {
r.Route("/me", func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Get("/", api.getUserHandler)
r.Patch("/", api.checkProfileOwnership(api.updateUserHandler))
r.Patch("/password", api.checkProfileOwnership(api.updatePasswordHandler))
r.Put(("/follow"), api.followUserHandler)
r.Put(("/unfollow"), api.unfollowUserHandler)
})
r.Group(func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Get("/feed", api.getUserFeedHandler)
})
})
r.Route("/authentication", func(r chi.Router) {
r.Post("/user", api.registerUserHandler)
r.Post("/register", api.registerUserHandler)
r.Post("/token", api.createTokenHandler)
r.Post("/forgot-password", api.forgotPasswordHandler)
r.Post("/refresh", api.refreshTokenHandler)
})
})
r.Route("/admin", func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Route("/users", func(r chi.Router) {
r.Get("/", api.protectAdminRoutes("moderator", api.getUsersHandler))
r.Route("/{id}", func(r chi.Router) {
r.Get("/", api.protectAdminRoutes("moderator", api.getUserByIdHandler))
})
})
})
return r
}
@@ -204,6 +216,12 @@ func (api *api) run(mux http.Handler) error {
return err
}
if userStore, ok := api.store.Users.(*store.UserStore); ok {
if err := userStore.Close(); err != nil {
api.logger.Errorw("error closing userstore", err)
}
}
api.logger.Infow("server has been shutdown")
return nil
+88 -1
View File
@@ -27,6 +27,15 @@ type UserWithToken struct {
Token string `json:"token"`
}
type ForgotPasswordPayload struct {
Email string `json:"email" validate:"required,email,max=255"`
}
type AuthResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
}
// RegisterUser godoc
//
// @Summary Register a new User
@@ -75,7 +84,7 @@ func (api *api) registerUserHandler(w http.ResponseWriter, r *http.Request) {
hashedToken := hex.EncodeToString(hash[:])
// Store the user
err := api.store.Users.CreateAndInvite(ctx, user, hashedToken, api.config.mail.exp)
err := api.store.Users.CreateAndInvite(ctx, user, hashedToken, api.config.mail.inviteExp)
if err != nil {
switch {
case errors.Is(err, store.ErrDuplicateEmail):
@@ -201,3 +210,81 @@ func (api *api) createTokenHandler(w http.ResponseWriter, r *http.Request) {
return
}
}
// ResetPasswordHandler godoc
//
// @Summary Forgot Password
// @Description Forgot Password
// @Tags authentication
// @Accept json
// @Produce json
// @Param payload body ForgotPasswordPayload true "User Credentials"
// @Success 201 {string} string "Token Created"
// @Failure 400 {object} error
// @Failure 401 {object} error
// @Failure 500 {object} error
// @Router /authentication/forgot-password [post]
func (api *api) forgotPasswordHandler(w http.ResponseWriter, r *http.Request) {
var payload ForgotPasswordPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
user, err := api.store.Users.GetByEmail(r.Context(), payload.Email)
if err != nil {
switch err {
case store.ErrNotFound:
api.badRequestError(w, r, err)
default:
api.internalServerError(w, r, err)
}
}
plainToken := uuid.New().String()
hash := sha256.Sum256([]byte(plainToken))
hashedToken := hex.EncodeToString(hash[:])
//*Store The Request
err = api.store.Users.PasswordResetRequest(r.Context(), user.ID, hashedToken, api.config.mail.passwordRefreshExp)
if err != nil {
api.internalServerError(w, r, err)
return
}
updateURL := fmt.Sprintf("%s/reset-password/%s", api.config.frontEndURL, plainToken)
isProdEnv := api.config.env == "production"
vars := struct {
Username string
ServiceName string
UpdateURL string
Email string
CurrentYear string
}{
Username: user.Username,
ServiceName: "LK_API_Temp",
UpdateURL: updateURL,
Email: user.Email,
CurrentYear: time.Now().Format("2006"),
}
status, err := api.mailer.Send(mailer.ForgotPasswordTemplate, user.Username, user.Email, vars, !isProdEnv)
if err != nil {
api.internalServerError(w, r, err)
return
}
api.logger.Infow("email sent", "status code", status)
if err := api.jsonResponse(w, http.StatusCreated, plainToken); err != nil {
api.internalServerError(w, r, err)
return
}
}
-2
View File
@@ -1,7 +1,6 @@
package main
import (
"log"
"net/http"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
@@ -48,7 +47,6 @@ func (api *api) getUserFeedHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := getUserFromContext(r)
log.Println(user)
feed, err := api.store.Posts.GetUserFeed(ctx, user.ID, fq)
if err != nil {
api.internalServerError(w, r, err)
+7 -3
View File
@@ -56,8 +56,9 @@ func main() {
},
env: env.GetString("ENV", "development"),
mail: mailConfig{
exp: time.Hour * 24 * 3,
fromEmail: env.GetString("FROM_EMAIL", ""),
inviteExp: time.Hour * 24 * 3,
passwordRefreshExp: time.Hour * 24 * 7,
fromEmail: env.GetString("FROM_EMAIL", ""),
sendGrid: sendGridConfig{
apiKey: env.GetString("SENDGRID_API_KEY", ""),
},
@@ -111,7 +112,10 @@ func main() {
cfg.rateLimiter.TimeFrame,
)
store := store.NewStorage(db)
store, err := store.NewStorage(db)
if err != nil {
logger.Fatal(err)
}
cacheStorage := cache.NewRedisStorage(redisDb)
mailer := mailer.NewSendGrid(cfg.mail.sendGrid.apiKey, cfg.mail.fromEmail)
+26 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
@@ -104,17 +105,20 @@ func (api *api) postsContextMiddleware(next http.Handler) http.Handler {
api.badRequestError(w, r, err)
return
}
ctx := r.Context()
post, err := api.store.Posts.GetByID(ctx, id)
log.Println("post", post)
if err != nil {
switch {
case errors.Is(err, store.ErrNotFound):
api.notFoundError(w, r, err)
return
default:
api.internalServerError(w, r, err)
return
}
return
}
ctx = context.WithValue(ctx, postCtx, post)
@@ -161,6 +165,27 @@ func (api *api) checkProfileOwnership(next http.HandlerFunc) http.HandlerFunc {
})
}
func (api *api) protectAdminRoutes(requiredRole string, next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
allowed, err := api.checkRolePrecedence(r.Context(), user, requiredRole)
if err != nil {
api.internalServerError(w, r, err)
return
}
if !allowed {
api.forbiddenError(w, r, fmt.Errorf("forbidden"))
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, roleContextKey, user.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (api *api) checkRolePrecedence(ctx context.Context, user *store.User, roleName string) (bool, error) {
role, err := api.store.Roles.GetByName(ctx, roleName)
if err != nil {
+39 -3
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"errors"
"log"
"net/http"
"strconv"
@@ -12,8 +11,39 @@ import (
)
type userKey string
type roleKey string
const userContextKey userKey = "user"
const roleContextKey roleKey = "role"
// GetUser godoc
//
// @Summary Fetches all users
// @Description Fetches all users
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} store.User
// @Failure 400 {object} error
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /admin/users [get]
func (api *api) getUsersHandler(w http.ResponseWriter, r *http.Request) {
role := getRoleFromContext(r)
isAdmin := false
if role.Level == 3 {
isAdmin = true
}
users, err := api.store.Users.GetAll(r.Context(), isAdmin)
if err != nil {
api.internalServerError(w, r, err)
return
}
api.jsonResponse(w, http.StatusOK, users)
}
// GetUser godoc
//
@@ -78,7 +108,6 @@ type updateUserPayload struct {
// @Router /users/{id} [patch]
func (api *api) updateUserHandler(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
log.Println(user)
var payload updateUserPayload
if err := readJSON(w, r, &payload); err != nil {
@@ -103,7 +132,9 @@ func (api *api) updateUser(ctx context.Context, user *store.User) error {
return err
}
api.cacheStore.Users.Delete(ctx, user.ID)
if api.config.redisCfg.enabled {
api.cacheStore.Users.Delete(ctx, user.ID)
}
return nil
}
@@ -300,3 +331,8 @@ func getUserFromContext(r *http.Request) *store.User {
user, _ := r.Context().Value(userContextKey).(*store.User)
return user
}
func getRoleFromContext(r *http.Request) *store.Role {
role, _ := r.Context().Value(roleContextKey).(*store.Role)
return role
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS password_reset_requests;
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS password_reset_requests (
token bytea PRIMARY KEY,
user_id bigint NOT NULL,
expiry TIMESTAMP(0)
WITH
TIME ZONE NOT NULL
);
+5 -1
View File
@@ -16,6 +16,10 @@ func main() {
}
defer conn.Close()
store := store.NewStorage(conn)
store, err := store.NewStorage(conn)
if err != nil {
log.Fatalf("could not create storage: %v", err)
}
db.Seed(store, conn)
}
+58
View File
@@ -24,6 +24,52 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/authentication/forgot-password": {
"post": {
"description": "Forgot Password",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Forgot Password",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.ForgotPasswordPayload"
}
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/token": {
"post": {
"description": "Create a new token for the user",
@@ -795,6 +841,18 @@ const docTemplate = `{
}
}
},
"main.ForgotPasswordPayload": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255
}
}
},
"main.RegisterUserPayload": {
"type": "object",
"required": [
+58
View File
@@ -16,6 +16,52 @@
},
"basePath": "/v1",
"paths": {
"/authentication/forgot-password": {
"post": {
"description": "Forgot Password",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Forgot Password",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.ForgotPasswordPayload"
}
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/token": {
"post": {
"description": "Create a new token for the user",
@@ -787,6 +833,18 @@
}
}
},
"main.ForgotPasswordPayload": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255
}
}
},
"main.RegisterUserPayload": {
"type": "object",
"required": [
+39
View File
@@ -29,6 +29,14 @@ definitions:
- email
- password
type: object
main.ForgotPasswordPayload:
properties:
email:
maxLength: 255
type: string
required:
- email
type: object
main.RegisterUserPayload:
properties:
email:
@@ -230,6 +238,37 @@ info:
termsOfService: http://swagger.io/terms/
title: LK API Template
paths:
/authentication/forgot-password:
post:
consumes:
- application/json
description: Forgot Password
parameters:
- description: User Credentials
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.ForgotPasswordPayload'
produces:
- application/json
responses:
"201":
description: Token Created
schema:
type: string
"400":
description: Bad Request
schema: {}
"401":
description: Unauthorized
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Forgot Password
tags:
- authentication
/authentication/token:
post:
consumes:
+3
View File
@@ -20,6 +20,7 @@ require (
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -31,6 +32,8 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+7
View File
@@ -1,5 +1,7 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -42,6 +44,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -64,6 +70,7 @@ github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBU
github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
+4 -3
View File
@@ -3,9 +3,10 @@ package mailer
import "embed"
const (
FromName = "LK API"
maxRetries = 3
UserWelcomeTemplate = "user_activation.tmpl"
FromName = "LK API"
maxRetries = 3
UserWelcomeTemplate = "user_activation.tmpl"
ForgotPasswordTemplate = "forgot_password.tmpl"
)
//go:embed "templates"
@@ -0,0 +1,27 @@
{{define "subject"}} Reset Your Password - {{.ServiceName}} {{end}}
{{define "body"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hello {{.Username}},</p>
<p>We received a request to reset your password. If you didn't make this request, you can ignore this email.</p>
<p>To reset your password, click on the link below:</p>
<p><a href="{{.UpdateURL}}">{{.UpdateURL}}</a></p>
<p>If you want to reset your password manually, copy and paste the link above into your browser.</p>
<p>If you have any questions or need assistance, please contact our support team.</p>
<p>Thanks,</p>
<p>The {{.ServiceName}} Team</p>
</body>
</html>
{{end}}
-3
View File
@@ -1,7 +1,6 @@
package ratelimiter
import (
"log"
"sync"
"time"
)
@@ -26,8 +25,6 @@ func (rl *FixedWindowLimiter) Allow(ip string) (bool, time.Duration) {
count, exists := rl.clients[ip]
rl.RUnlock()
log.Println(count, exists)
if !exists || count < rl.limit {
rl.Lock()
if !exists {
+5
View File
@@ -48,6 +48,11 @@ func (m *MockUserStore) CreateAndInvite(ctx context.Context, user *User, token s
return args.Error(0)
}
func (m *MockUserStore) PasswordResetRequest(ctx context.Context, userID int64, token string, expiration time.Duration) error {
args := m.Called(ctx, userID, token, expiration)
return args.Error(0)
}
func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
+16 -4
View File
@@ -14,21 +14,33 @@ type Role struct {
type RoleStore struct {
db *sql.DB
getByNameStmt *sql.Stmt
}
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) {
query := `
func NewRoleStore(db *sql.DB) (*RoleStore, error) {
getByNameStmt, err := db.PrepareContext(context.Background(), `
SELECT id, name, level, description
FROM roles
WHERE name = $1
`
`)
if err != nil {
return nil, err
}
return &RoleStore{
db: db,
getByNameStmt: getByNameStmt,
}, nil
}
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := &Role{}
err := s.db.QueryRowContext(ctx, query, name).Scan(
err := s.getByNameStmt.QueryRowContext(ctx, name).Scan(
&role.ID,
&role.Name,
&role.Level,
+25 -4
View File
@@ -31,6 +31,7 @@ type Storage struct {
GetByEmail(context.Context, string) (*User, error)
Update(context.Context, *User) error
CreateAndInvite(context.Context, *User, string, time.Duration) error
PasswordResetRequest(context.Context, int64, string, time.Duration) error
Delete(context.Context, int64) error
DeleteUser(context.Context, int64) error
}
@@ -47,14 +48,20 @@ type Storage struct {
}
}
func NewStorage(db *sql.DB) Storage {
func NewStorage(db *sql.DB) (Storage, error) {
userStore, roleStore, err := initStores(db)
if err != nil {
return Storage{}, err
}
return Storage{
Posts: &PostStore{db},
Users: &UserStore{db},
Users: userStore,
Comments: &CommentStore{db},
Followers: &FollowerStore{db},
Roles: &RoleStore{db},
}
Roles: roleStore,
}, nil
}
func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
@@ -70,3 +77,17 @@ func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
return tx.Commit()
}
func initStores(db *sql.DB) (*UserStore, *RoleStore, error) {
userStore, err := NewUserStore(db)
if err != nil {
return &UserStore{}, &RoleStore{}, err
}
roleStore, err := NewRoleStore(db)
if err != nil {
return &UserStore{}, &RoleStore{}, err
}
return userStore, roleStore, nil
}
+196 -64
View File
@@ -8,6 +8,8 @@ import (
"time"
"golang.org/x/crypto/bcrypt"
sq "github.com/Masterminds/squirrel"
)
type User struct {
@@ -29,6 +31,8 @@ type password struct {
hash []byte
}
var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
func (p *password) Set(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -42,22 +46,141 @@ func (p *password) Set(password string) error {
type UserStore struct {
db *sql.DB
getByIDStmt *sql.Stmt
getByEmailStmt *sql.Stmt
getByInvitationStmt *sql.Stmt
updateStmt *sql.Stmt
deleteStmt *sql.Stmt
inviteCreateStmt *sql.Stmt
invitationDeleteStmt *sql.Stmt
passwordResetRequestCreateStmt *sql.Stmt
}
func NewUserStore(db *sql.DB) (*UserStore, error) {
store := &UserStore{
db: db,
}
var err error
store.getByIDStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.password, u.is_active, u.created_at, u.updated_at, roles.*
FROM users u
JOIN roles ON u.role_id = roles.id
WHERE u.id = $1 AND u.is_active = true
`)
if err != nil {
return nil, err
}
store.getByEmailStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.password, u.is_active, u.created_at, u.updated_at, roles.*
FROM users u
JOIN roles ON u.role_id = roles.id
WHERE u.email = $1 AND u.is_active = true
`)
if err != nil {
return nil, err
}
store.getByInvitationStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`)
if err != nil {
return nil, err
}
store.updateStmt, err = db.Prepare(`
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5
WHERE id = $6
`)
if err != nil {
return nil, err
}
store.deleteStmt, err = db.Prepare(`
DELETE FROM users WHERE id = $1
`)
if err != nil {
return nil, err
}
store.invitationDeleteStmt, err = db.Prepare(`
DELETE FROM invitations WHERE user_id = $1
`)
if err != nil {
return nil, err
}
store.inviteCreateStmt, err = db.Prepare(`
INSERT INTO invitations (user_id, token, expiry) VALUES ($1, $2, $3)
`)
if err != nil {
return nil, err
}
store.passwordResetRequestCreateStmt, err = db.Prepare(`
INSERT INTO password_reset_requests (user_id, token, expiry) VALUES ($1, $2, $3)
`)
if err != nil {
return nil, err
}
return store, nil
}
func (s *UserStore) Close() error {
var errs []error
if err := s.getByIDStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.getByEmailStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.getByInvitationStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.updateStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.deleteStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.invitationDeleteStmt.Close(); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return errs[0]
}
return nil
}
func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
INSERT INTO users (first_name, last_name, username, email, password, role_id)
VALUES ($1, $2, $3, $4, $5, (SELECT id FROM roles WHERE name = $6)) RETURNING id, created_at, updated_at
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := user.Role.Name
if role == "" {
role = "user"
}
err := tx.QueryRowContext(
query, _, err := psql.Insert("users").
Columns("first_name", "last_name", "username", "email", "password", "role_id").
Values(user.FirstName, user.LastName, user.Username, user.Email, user.Password.hash, sq.Expr("(SELECT id FROM roles WHERE name = ?)", role)).
Suffix("RETURNING id, created_at, updated_at").ToSql()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err = tx.QueryRowContext(
ctx,
query,
user.FirstName,
@@ -81,28 +204,26 @@ func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
return nil
}
func (s *UserStore) GetAll(ctx context.Context, isAdmin bool) ([]User, error) {
}
func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE users.id = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := s.db.QueryRowContext(ctx, query, id).Scan(
err := s.getByIDStmt.QueryRowContext(ctx, id).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.Password.hash,
&user.IsActive,
&user.CreatedAt,
&user.UpdatedAt,
&user.Role.ID,
&user.RoleID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
@@ -115,25 +236,16 @@ func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
return nil, err
}
}
return user, nil
}
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE email = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := s.db.QueryRowContext(ctx, query, email).Scan(
err := s.getByEmailStmt.QueryRowContext(ctx, email).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
@@ -142,7 +254,8 @@ func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error)
&user.Password.hash,
&user.CreatedAt,
&user.UpdatedAt,
&user.Role.ID,
&user.IsActive,
&user.RoleID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
@@ -160,17 +273,11 @@ func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error)
}
func (s *UserStore) Update(ctx context.Context, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(
_, err := s.updateStmt.ExecContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
@@ -199,10 +306,20 @@ func (s *UserStore) CreateAndInvite(ctx context.Context, user *User, token strin
})
}
func (s *UserStore) PasswordResetRequest(ctx context.Context, userID int64, token string, expiration time.Duration) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
if err := s.createPasswordResetRequest(ctx, tx, userID, token, expiration); err != nil {
return err
}
return nil
})
}
func (s *UserStore) Activate(ctx context.Context, token string) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
// Find the user that this token belongs to
user, err := getUserFromInvitation(ctx, tx, token)
user, err := s.getUserFromInvitation(ctx, tx, token)
if err != nil {
return err
}
@@ -238,12 +355,18 @@ func (s *UserStore) Delete(ctx context.Context, id int64) error {
// * Soft delete user
func (s *UserStore) DeleteUser(ctx context.Context, id int64) error {
query := `UPDATE users SET is_active = false WHERE id = $1`
query, _, err := psql.Update("users").
Set("is_active", false).
Where(sq.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, id)
_, err = s.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
@@ -255,22 +378,18 @@ func (p *password) Compare(password string) error {
return bcrypt.CompareHashAndPassword(p.hash, []byte(password))
}
func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
query := `
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`
func (s *UserStore) getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
hashed := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(hashed[:])
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
stmt := tx.Stmt(s.getByInvitationStmt)
defer stmt.Close()
user := &User{}
err := tx.QueryRowContext(ctx, query, hashedToken, time.Now()).Scan(
err := stmt.QueryRowContext(ctx, hashedToken, time.Now()).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
@@ -293,14 +412,28 @@ func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User
}
func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error {
query := `
INSERT INTO invitations (user_id, token, expiry)
VALUES ($1, $2, $3)
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, userID, token, time.Now().Add(exp))
stmt := tx.Stmt(s.inviteCreateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID, token, time.Now().Add(exp))
if err != nil {
return err
}
return nil
}
func (s *UserStore) createPasswordResetRequest(ctx context.Context, tx *sql.Tx, userID int64, token string, expiry time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
stmt := tx.Stmt(s.passwordResetRequestCreateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID, token, time.Now().Add(expiry))
if err != nil {
return err
}
@@ -309,17 +442,14 @@ func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID
}
func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(
stmt := tx.Stmt(s.updateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
@@ -335,12 +465,13 @@ func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
}
func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error {
query := `DELETE FROM invitations WHERE user_id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, userID)
stmt := tx.Stmt(s.invitationDeleteStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID)
if err != nil {
return err
}
@@ -349,12 +480,13 @@ func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID
}
func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error {
query := `DELETE FROM users WHERE id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, id)
stmt := tx.Stmt(s.deleteStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, id)
if err != nil {
return err
}