package application
import (
"github.com/jessicatarra/greenlight/internal/concurrent"
"github.com/jessicatarra/greenlight/internal/config"
"github.com/jessicatarra/greenlight/internal/mailer"
"github.com/jessicatarra/greenlight/ms/auth/internal/domain"
"github.com/jessicatarra/greenlight/ms/auth/internal/infrastructure/repositories"
"github.com/pascaldekloe/jwt"
"strconv"
"sync"
"time"
)
type appl struct {
userRepo domain.UserRepository
tokenRepo domain.TokenRepository
permissionRepo domain.PermissionRepository
concurrent concurrent.Resource
mailer mailer.Mailer
cfg config.Config
}
func NewAppl(userRepo domain.UserRepository, tokenRepo domain.TokenRepository, permissionRepo domain.PermissionRepository, wg *sync.WaitGroup, cfg config.Config) domain.Appl {
return &appl{
userRepo: userRepo,
tokenRepo: tokenRepo,
permissionRepo: permissionRepo,
concurrent: concurrent.NewBackgroundTask(wg),
mailer: mailer.New(cfg.Smtp.Host, cfg.Smtp.Port, cfg.Smtp.Username, cfg.Smtp.Password, cfg.Smtp.From),
cfg: cfg,
}
}
func (a *appl) CreateUseCase(input *domain.CreateUserRequest, hashedPassword string) (*domain.User, error) {
user := &domain.User{Name: input.Name, Email: input.Email, Activated: false}
err := a.userRepo.InsertNewUser(user, hashedPassword)
if err != nil {
return nil, err
}
err = a.permissionRepo.AddForUser(user.ID, "movies:read")
if err != nil {
return nil, err
}
token, err := a.tokenRepo.New(user.ID, 3*24*time.Hour, repositories.ScopeActivation)
if err != nil {
return nil, err
}
fn := func() error {
data := map[string]interface{}{
"activationToken": token.Plaintext,
"userID": user.ID,
}
//print(token.Plaintext)
err = a.mailer.Send(user.Email, "user_welcome.gohtml", data)
if err != nil {
return err
}
return nil
}
a.concurrent.BackgroundTask(fn)
return user, err
}
func (a *appl) ActivateUseCase(tokenPlainText string) (*domain.User, error) {
user, err := a.userRepo.GetForToken(repositories.ScopeActivation, tokenPlainText)
if err != nil {
return nil, err
}
user.Activated = true
err = a.userRepo.UpdateUser(user)
if err != nil {
return nil, err
}
err = a.tokenRepo.DeleteAllForUser(repositories.ScopeActivation, user.ID)
if err != nil {
return nil, err
}
return user, err
}
func (a *appl) GetByEmailUseCase(email string) (*domain.User, error) {
existingUser, err := a.userRepo.GetUserByEmail(email)
if err != nil {
return nil, err
}
return existingUser, nil
}
func (a *appl) CreateAuthTokenUseCase(userID int64) ([]byte, error) {
var claims jwt.Claims
claims.Subject = strconv.FormatInt(userID, 10)
claims.Issued = jwt.NewNumericTime(time.Now())
claims.NotBefore = jwt.NewNumericTime(time.Now())
claims.Expires = jwt.NewNumericTime(time.Now().Add(24 * time.Hour))
claims.Issuer = a.cfg.Auth.HttpBaseURL
claims.Audiences = []string{a.cfg.Auth.HttpBaseURL}
jwtBytes, err := claims.HMACSign(jwt.HS256, []byte(a.cfg.Jwt.Secret))
if err != nil {
return nil, err
}
return jwtBytes, nil
}
func (a *appl) ValidateAuthTokenUseCase(token string) (*domain.User, error) {
claims, err := jwt.HMACCheck([]byte(token), []byte(a.cfg.Jwt.Secret))
if err != nil {
return nil, err
}
if !claims.Valid(time.Now()) {
return nil, err
}
if claims.Issuer != a.cfg.Auth.HttpBaseURL {
return nil, err
}
if !claims.AcceptAudience(a.cfg.Auth.HttpBaseURL) {
return nil, err
}
userID, err := strconv.Atoi(claims.Subject)
if err != nil {
return nil, err
}
user, err := a.userRepo.GetUserById(int64(userID))
if err != nil {
return nil, err
}
return user, nil
}
func (a *appl) UserPermissionUseCase(code string, userID int64) error {
permissions, err := a.permissionRepo.GetAllForUser(userID)
if err != nil {
return err
}
if !permissions.Include(code) {
return domain.ErrPermissionNotIncluded
}
return nil
}
package http
import (
"errors"
_errors "github.com/jessicatarra/greenlight/internal/errors"
"github.com/jessicatarra/greenlight/internal/password"
"github.com/jessicatarra/greenlight/internal/request"
"github.com/jessicatarra/greenlight/internal/response"
"github.com/jessicatarra/greenlight/internal/utils/helpers"
"github.com/jessicatarra/greenlight/ms/auth/internal/domain"
"github.com/julienschmidt/httprouter"
"net/http"
)
type envelope map[string]interface{}
type Handlers interface {
createUser(res http.ResponseWriter, req *http.Request)
activateUser(res http.ResponseWriter, req *http.Request)
createAuthenticationToken(res http.ResponseWriter, req *http.Request)
}
type handlers struct {
appl domain.Appl
helpers helpers.Helpers
}
func (s service) Handlers(router *httprouter.Router) {
res := registerHandlers(s.appl)
router.HandlerFunc(http.MethodPost, "/v1/users", res.createUser)
router.HandlerFunc(http.MethodPut, "/v1/users/activated", res.activateUser)
router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", res.createAuthenticationToken)
}
func registerHandlers(appl domain.Appl) Handlers {
return &handlers{
appl: appl,
helpers: helpers.New(),
}
}
// @Summary Register User
// @Description Registers a new user.
// @Tags Users
// @Accept json
// @Produce json
// @Param name body domain.CreateUserRequest true "User registration data"
// @Success 201 {object} domain.User
// @Router /users [post]
func (h *handlers) createUser(res http.ResponseWriter, req *http.Request) {
var input domain.CreateUserRequest
err := request.DecodeJSON(res, req, &input)
if err != nil {
_errors.BadRequest(res, req, err)
return
}
existingUser, err := h.appl.GetByEmailUseCase(input.Email)
if err != nil && err.Error() != domain.ErrRecordNotFound.Error() {
_errors.ServerError(res, req, err)
return
}
ValidateUser(&input, existingUser)
if input.Validator.HasErrors() {
_errors.FailedValidation(res, req, input.Validator)
return
}
hashedPassword, err := password.Hash(input.Password)
if err != nil {
_errors.ServerError(res, req, err)
return
}
user, err := h.appl.CreateUseCase(&input, hashedPassword)
if err != nil {
switch {
case errors.Is(err, domain.ErrDuplicateEmail):
input.Validator.AddError("email a user with this email address already exists")
_errors.FailedValidation(res, req, input.Validator)
default:
_errors.ServerError(res, req, err)
}
return
}
err = response.JSON(res, http.StatusCreated, envelope{"user": user})
if err != nil {
_errors.ServerError(res, req, err)
}
}
// @Summary Activate User
// @Description Activates a user account using a token that was previously sent when successfully register a new user
// @Tags Users
// @Accept json
// @Produce json
// @Param token query string true "Token for user activation"
// @Success 200 {object} domain.User
// @Router /users/activated [put]
func (h *handlers) activateUser(res http.ResponseWriter, req *http.Request) {
var input domain.ActivateUserRequest
qs := req.URL.Query()
input.TokenPlaintext = h.helpers.ReadString(qs, "token", "")
ValidateToken(&input)
if input.Validator.HasErrors() {
_errors.FailedValidation(res, req, input.Validator)
return
}
user, err := h.appl.ActivateUseCase(input.TokenPlaintext)
if err != nil {
_errors.ServerError(res, req, err)
return
}
err = response.JSON(res, http.StatusCreated, envelope{"user": user})
if err != nil {
_errors.ServerError(res, req, err)
}
}
// @Summary Create authentication token
// @Description Creates an authentication token for a user
// @Tags Authentication
// @Accept json
// @Produce json
// @Param request body domain.CreateAuthTokenRequest true "Request body"
// @Success 201 {object} domain.Token "Authentication token"
// @Router /tokens/authentication [post]
func (h *handlers) createAuthenticationToken(res http.ResponseWriter, req *http.Request) {
var input domain.CreateAuthTokenRequest
err := request.DecodeJSON(res, req, &input)
if err != nil {
_errors.BadRequest(res, req, err)
return
}
existingUser, err := h.appl.GetByEmailUseCase(input.Email)
if err != nil {
switch {
case errors.Is(err, domain.ErrRecordNotFound):
_errors.InvalidAuthenticationToken(res, req)
default:
_errors.ServerError(res, req, err)
}
return
}
ValidateEmailForAuth(&input, existingUser)
if existingUser != nil {
passwordMatches, err := password.Matches(input.Password, existingUser.HashedPassword)
if err != nil {
_errors.ServerError(res, req, err)
return
}
ValidatePasswordForAuth(&input, passwordMatches)
}
if input.Validator.HasErrors() {
_errors.FailedValidation(res, req, input.Validator)
return
}
jwtBytes, err := h.appl.CreateAuthTokenUseCase(existingUser.ID)
if err != nil {
_errors.ServerError(res, req, err)
return
}
err = response.JSON(res, http.StatusCreated, envelope{"authentication_token": string(jwtBytes)})
if err != nil {
_errors.ServerError(res, req, err)
}
}
package http
import (
"github.com/jessicatarra/greenlight/internal/middleware"
"github.com/julienschmidt/httprouter"
"net/http"
)
func (s service) Routes() http.Handler {
router := httprouter.New()
s.Handlers(router)
m := middleware.NewSharedMiddleware(&s.cfg, s.logger)
return m.RecoverPanic(m.RateLimit(m.EnableCORS(m.LogRequest(router))))
}
package http
import (
"github.com/jessicatarra/greenlight/internal/config"
"github.com/jessicatarra/greenlight/ms/auth/internal/domain"
"github.com/julienschmidt/httprouter"
"log/slog"
"net/http"
)
type Service interface {
Routes() http.Handler
Handlers(router *httprouter.Router)
}
type service struct {
appl domain.Appl
cfg config.Config
logger *slog.Logger
}
func NewService(appl domain.Appl, cfg config.Config, logger *slog.Logger) Service {
return &service{
appl: appl,
cfg: cfg,
logger: logger,
}
}
package http
import (
"github.com/jessicatarra/greenlight/internal/password"
"github.com/jessicatarra/greenlight/internal/utils/validator"
"github.com/jessicatarra/greenlight/ms/auth/internal/domain"
)
func ValidateUser(input *domain.CreateUserRequest, existingUser *domain.User) {
input.Validator.CheckField(input.Name != "", "name", "must be provided")
input.Validator.CheckField(len(input.Name) <= 500, "name", "must not be more than 500 bytes long")
ValidateEmail(input, existingUser)
ValidatePassword(input)
}
func ValidatePassword(input *domain.CreateUserRequest) {
input.Validator.CheckField(input.Password != "", "Password", "Password is required")
input.Validator.CheckField(len(input.Password) >= 8, "Password", "Password is too short")
input.Validator.CheckField(len(input.Password) <= 72, "Password", "Password is too long")
input.Validator.CheckField(validator.NotIn(input.Password, password.CommonPasswords...), "Password", "Password is too common")
}
func ValidateEmail(input *domain.CreateUserRequest, existingUser *domain.User) {
input.Validator.CheckField(input.Email != "", "Email", "Email is required")
input.Validator.CheckField(validator.Matches(input.Email, validator.RgxEmail), "Email", "Must be a valid email address")
input.Validator.CheckField(existingUser == nil, "Email", "Email is already in use")
}
func ValidateToken(input *domain.ActivateUserRequest) {
input.Validator.Check(input.TokenPlaintext != "", "token must be provided")
input.Validator.Check(len(input.TokenPlaintext) == 26, "token must be 26 bytes long")
}
func ValidateEmailForAuth(input *domain.CreateAuthTokenRequest, existingUser *domain.User) {
input.Validator.CheckField(input.Email != "", "Email", "Email is required")
input.Validator.CheckField(existingUser != nil, "Email", "Email address could not be found")
}
func ValidatePasswordForAuth(input *domain.CreateAuthTokenRequest, passwordMatches bool) {
input.Validator.CheckField(input.Password != "", "Password", "Password is required")
input.Validator.CheckField(passwordMatches, "Password", "Password is incorrect")
}
package repositories
import (
"context"
"database/sql"
"github.com/jessicatarra/greenlight/ms/auth/internal/domain"
"github.com/lib/pq"
"time"
)
type permissionRepository struct {
db *sql.DB
}
func NewPermissionRepo(db *sql.DB) domain.PermissionRepository {
return &permissionRepository{db: db}
}
func (p permissionRepository) GetAllForUser(userID int64) (domain.Permissions, error) {
query := `
SELECT permissions.code
FROM permissions
INNER JOIN users_permissions ON users_permissions.permission_id = permissions.id
INNER JOIN users ON users_permissions.user_id = users.id
WHERE users.id = $1`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rows, err := p.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var permissions domain.Permissions
for rows.Next() {
var permission string
err := rows.Scan(&permission)
if err != nil {
return nil, err
}
permissions = append(permissions, permission)
}
if err = rows.Err(); err != nil {
return nil, err
}
return permissions, nil
}
func (p permissionRepository) AddForUser(userID int64, codes ...string) error {
query := `
INSERT INTO users_permissions
SELECT $1, permissions.id FROM permissions WHERE permissions.code = ANY($2)`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := p.db.ExecContext(ctx, query, userID, pq.Array(codes))
return err
}
package repositories
import (
"context"
"database/sql"
"github.com/jessicatarra/greenlight/ms/auth/internal/domain"
"time"
)
const (
ScopeActivation = "activation"
ScopeAuthentication = "authentication"
)
type tokenRepository struct {
db *sql.DB
token domain.TokenInterface
}
func NewTokenRepo(db *sql.DB) domain.TokenRepository {
return &tokenRepository{db: db, token: domain.NewToken()}
}
func (t *tokenRepository) New(userID int64, ttl time.Duration, scope string) (*domain.Token, error) {
token, err := t.token.GenerateToken(userID, ttl, scope)
if err != nil {
return nil, err
}
err = t.Insert(token)
return token, err
}
func (t *tokenRepository) Insert(token *domain.Token) error {
query := `
INSERT INTO tokens (hash, user_id, expiry, scope)
VALUES ($1, $2, $3, $4)`
args := []interface{}{token.Hash, token.UserID, token.Expiry, token.Scope}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
_, err := t.db.ExecContext(ctx, query, args...)
return err
}
func (t *tokenRepository) DeleteAllForUser(scope string, userID int64) error {
query := `
DELETE FROM tokens
WHERE scope = $1 AND user_id = $2`
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
_, err := t.db.ExecContext(ctx, query, scope, userID)
return err
}
package repositories
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/jessicatarra/greenlight/ms/auth/internal/domain"
_ "github.com/lib/pq"
"time"
)
const defaultTimeout = 10 * time.Second
type userRepository struct {
db *sql.DB
}
func NewUserRepo(db *sql.DB) domain.UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) InsertNewUser(user *domain.User, hashedPassword string) error {
query := `
INSERT INTO users (name, email, password_hash, activated)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at, version`
args := []interface{}{user.Name, user.Email, hashedPassword, user.Activated}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
err := r.db.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Version)
if err != nil {
switch {
case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`:
return domain.ErrDuplicateEmail
default:
return err
}
}
return nil
}
func (r *userRepository) GetUserByEmail(email string) (*domain.User, error) {
query := `
SELECT id, created_at, name, email, password_hash, activated, version
FROM users
WHERE email = $1`
var user domain.User
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
err := r.db.QueryRowContext(ctx, query, email).Scan(
&user.ID,
&user.CreatedAt,
&user.Name,
&user.Email,
&user.HashedPassword,
&user.Activated,
&user.Version,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, domain.ErrRecordNotFound
default:
return nil, err
}
}
return &user, nil
}
func (r *userRepository) UpdateUser(user *domain.User) error {
query := `
UPDATE users SET name = $1, email = $2, password_hash = $3, activated = $4, version = version + 1
WHERE id = $5 AND version = $6
RETURNING version`
args := []interface{}{
user.Name,
user.Email,
user.HashedPassword,
user.Activated,
user.ID,
user.Version,
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
err := r.db.QueryRowContext(ctx, query, args...).Scan(&user.Version)
if err != nil {
switch {
case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`:
return domain.ErrDuplicateEmail
case errors.Is(err, sql.ErrNoRows):
return domain.ErrEditConflict
default:
return err
}
}
return nil
}
func (r *userRepository) GetForToken(tokenScope string, tokenPlaintext string) (*domain.User, error) {
tokenHash := sha256.Sum256([]byte(tokenPlaintext))
query := `
SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version
FROM users
INNER JOIN tokens
ON users.id = tokens.user_id
WHERE tokens.hash = $1
AND tokens.scope = $2
AND tokens.expiry > $3`
args := []interface{}{tokenHash[:], tokenScope, time.Now()}
var user domain.User
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
err := r.db.QueryRowContext(ctx, query, args...).Scan(
&user.ID,
&user.CreatedAt,
&user.Name,
&user.Email,
&user.HashedPassword,
&user.Activated,
&user.Version,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, domain.ErrRecordNotFound
default:
return nil, err
}
}
return &user, nil
}
func (r *userRepository) GetUserById(id int64) (*domain.User, error) {
query := `
SELECT id, created_at, name, email, password_hash, activated, version
FROM users
WHERE id = $1`
var user domain.User
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.CreatedAt,
&user.Name,
&user.Email,
&user.HashedPassword,
&user.Activated,
&user.Version,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, domain.ErrRecordNotFound
default:
return nil, err
}
}
return &user, nil
}