Skip to content

Commit

Permalink
feat(backend): rework mail blast model
Browse files Browse the repository at this point in the history
  • Loading branch information
aldy505 committed Mar 11, 2024
1 parent 61e25ea commit 7717776
Show file tree
Hide file tree
Showing 25 changed files with 955 additions and 710 deletions.
6 changes: 2 additions & 4 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ FROM debian:bookworm-slim AS runtime

WORKDIR /app

RUN apt-get update && \
apt-get install -y curl && \
apt-get clean && \
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/csv && \
mkdir -p /data
Expand All @@ -22,7 +20,7 @@ ARG PORT=8080
COPY --from=build /app/ .

HEALTHCHECK --interval=60s --timeout=40s \
CMD curl -f http://localhost:8080/ping || exit 1
CMD /app/conf-backend --port ${PORT}

EXPOSE ${PORT}

Expand Down
60 changes: 60 additions & 0 deletions backend/administrator/administrator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package administrator

import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"fmt"

"conf/administrator/jwt"
"github.com/pquerna/otp/totp"
)

type Administrator struct {
Username string `yaml:"username"`
HashedPassword string `yaml:"hashed_password"`
TotpSecret string `yaml:"totp_secret"`
}

func GenerateSecret(username string) (secret string, url string, err error) {
generate, err := totp.Generate(totp.GenerateOpts{Issuer: "teknumconf", AccountName: username, Rand: rand.Reader})
if err != nil {
return "", "", err
}

return generate.Secret(), generate.URL(), nil
}

type AdministratorDomain struct {
jwt *jwt.JsonWebToken
administrators []Administrator
}

func NewAdministratorDomain(administrators []Administrator) (*AdministratorDomain, error) {
// Generate ed25519 key pairs for access and refresh tokens
accessPublicKey, accessPrivateKey, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, fmt.Errorf("generating fresh access key pair: %w", err)
}

refreshPublicKey, refreshPrivateKey, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, fmt.Errorf("generating fresh refresh key pair: %w", err)
}

var randomIssuer = make([]byte, 18)
_, _ = rand.Read(randomIssuer)

var randomSubject = make([]byte, 16)
_, _ = rand.Read(randomSubject)

var randomAudience = make([]byte, 32)
_, _ = rand.Read(randomAudience)

authJwt := jwt.NewJwt(accessPrivateKey, accessPublicKey, refreshPrivateKey, refreshPublicKey, hex.EncodeToString(randomIssuer), hex.EncodeToString(randomSubject), hex.EncodeToString(randomAudience))

return &AdministratorDomain{
jwt: authJwt,
administrators: administrators,
}, nil
}
55 changes: 55 additions & 0 deletions backend/administrator/authenticate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package administrator

import (
"context"
"encoding/hex"
"errors"
"fmt"

"github.com/getsentry/sentry-go"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)

func (a *AdministratorDomain) Authenticate(ctx context.Context, username string, plainPassword string, otpCode string) (string, bool, error) {
span := sentry.StartSpan(ctx, "administrator.authenticate", sentry.WithTransactionName("Authenticate"))
defer span.Finish()

var administrator Administrator
for _, adm := range a.administrators {
if adm.Username == username {
administrator = adm
break
}
}

if administrator.Username == "" {
return "", false, nil
}

hashedPassword, err := hex.DecodeString(administrator.HashedPassword)
if err != nil {
return "", false, fmt.Errorf("invalid hex string")
}

err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(plainPassword))
if err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return "", false, nil
}

return "", false, fmt.Errorf("password: %w", err)
}

ok := totp.Validate(otpCode, administrator.TotpSecret)
if !ok {
return "", false, nil
}

token, err := a.jwt.Sign(username)
if err != nil {
return "", false, fmt.Errorf("signing token: %w", err)
}

return token, true, nil
}
137 changes: 137 additions & 0 deletions backend/administrator/jwt/jwt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package jwt

import (
"crypto/ed25519"
"crypto/rand"
"errors"
"fmt"
"time"

"github.com/golang-jwt/jwt/v4"
)

type JsonWebToken struct {
accessPrivateKey ed25519.PrivateKey
accessPublicKey ed25519.PublicKey
refreshPrivateKey ed25519.PrivateKey
refreshPublicKey ed25519.PublicKey
issuer string
subject string
audience string
}

func NewJwt(accessPrivateKey []byte, accessPublicKey []byte, refreshPrivateKey []byte, refreshPublicKey []byte, issuer string, subject string, audience string) *JsonWebToken {
return &JsonWebToken{
accessPrivateKey: accessPrivateKey,
accessPublicKey: accessPublicKey,
refreshPrivateKey: refreshPrivateKey,
refreshPublicKey: refreshPublicKey,
issuer: issuer,
subject: subject,
audience: audience,
}
}

func (j *JsonWebToken) Sign(userId string) (accessToken string, err error) {
accessRandId := make([]byte, 32)
_, _ = rand.Read(accessRandId)

accessClaims := jwt.MapClaims{
"iss": j.issuer,
"sub": j.subject,
"aud": j.audience,
"exp": time.Now().Add(time.Hour * 1).Unix(),
"nbf": time.Now().Unix(),
"iat": time.Now().Unix(),
"jti": string(accessRandId),
"uid": userId,
}

accessToken, err = jwt.NewWithClaims(jwt.SigningMethodEdDSA, accessClaims).SignedString(j.accessPrivateKey)
if err != nil {
return "", fmt.Errorf("failed to sign access token: %w", err)
}

return accessToken, nil
}

var ErrInvalidSigningMethod = errors.New("invalid signing method")
var ErrExpired = errors.New("token expired")
var ErrInvalid = errors.New("token invalid")
var ErrClaims = errors.New("token claims invalid")

func (j *JsonWebToken) VerifyAccessToken(token string) (userId string, err error) {
if token == "" {
return "", ErrInvalid
}

parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
_, ok := t.Method.(*jwt.SigningMethodEd25519)
if !ok {
return nil, ErrInvalidSigningMethod
}
return j.accessPublicKey, nil
})
if err != nil {
if parsedToken != nil && !parsedToken.Valid {
// Check if the error is a type of jwt.ValidationError
validationError, ok := err.(*jwt.ValidationError)
if ok {
if validationError.Errors&jwt.ValidationErrorExpired != 0 {
return "", ErrExpired
}

if validationError.Errors&jwt.ValidationErrorSignatureInvalid != 0 {
return "", ErrInvalid
}

if validationError.Errors&jwt.ValidationErrorClaimsInvalid != 0 {
return "", ErrClaims
}

return "", fmt.Errorf("failed to parse access token: %w", err)
}

return "", fmt.Errorf("non-validation error during parsing token: %w", err)
}

return "", fmt.Errorf("token is valid or parsedToken is not nil: %w", err)
}

claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return "", ErrClaims
}

if !claims.VerifyAudience(j.audience, true) {
return "", ErrInvalid
}

if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
return "", ErrExpired
}

if !claims.VerifyIssuer(j.issuer, true) {
return "", ErrInvalid
}

if !claims.VerifyNotBefore(time.Now().Unix(), true) {
return "", ErrInvalid
}

jwtId, ok := claims["jti"].(string)
if !ok {
return "", ErrClaims
}

if jwtId == "" {
return "", ErrClaims
}

userId, ok = claims["uid"].(string)
if !ok {
return "", ErrClaims
}

return userId, nil
}
74 changes: 74 additions & 0 deletions backend/administrator/jwt/jwt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package jwt_test

import (
"crypto/ed25519"
"errors"
"log"
"os"
"testing"

"conf/administrator/jwt"
)

var authJwt *jwt.JsonWebToken

func TestMain(m *testing.M) {
// Generate ed25519 key pairs for access and refresh tokens
accessPublicKey, accessPrivateKey, err := ed25519.GenerateKey(nil)
if err != nil {
log.Fatalf("failed to generate access key pair: %v", err)
}

refreshPublicKey, refreshPrivateKey, err := ed25519.GenerateKey(nil)
if err != nil {
log.Fatalf("failed to generate refresh key pair: %v", err)
}

authJwt = jwt.NewJwt(accessPrivateKey, accessPublicKey, refreshPrivateKey, refreshPublicKey, "kodiiing", "user", "kodiiing")

exitCode := m.Run()

os.Exit(exitCode)
}

func TestSign(t *testing.T) {
accessToken, err := authJwt.Sign("john")
if err != nil {
t.Errorf("failed to sign access token: %v", err)
}

if accessToken == "" {
t.Error("access token is empty")
}
}

func TestVerify(t *testing.T) {
accessToken, err := authJwt.Sign("john")
if err != nil {
t.Errorf("failed to sign access token: %v", err)
}

if accessToken == "" {
t.Error("access token is empty")
}

accessId, err := authJwt.VerifyAccessToken(accessToken)
if err != nil {
t.Errorf("failed to verify access token: %v", err)
}

if accessId != "john" {
t.Errorf("access id is not 'john': %v", accessId)
}
}

func TestVerifyEmpty(t *testing.T) {
accessId, err := authJwt.VerifyAccessToken("")
if err == nil {
t.Errorf("access token is valid: %v", accessId)
}

if !errors.Is(err, jwt.ErrInvalid) {
t.Errorf("error is not ErrInvalid: %v", err)
}
}
31 changes: 31 additions & 0 deletions backend/administrator/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package administrator

import (
"context"

"github.com/getsentry/sentry-go"
)

func (a *AdministratorDomain) Validate(ctx context.Context, token string) (Administrator, bool, error) {
span := sentry.StartSpan(ctx, "administrator.validate", sentry.WithTransactionName("Validate"))
defer span.Finish()

if token == "" {
return Administrator{}, false, nil
}

username, err := a.jwt.VerifyAccessToken(token)
if err != nil {
return Administrator{}, false, nil
}

var administrator Administrator
for _, adm := range a.administrators {
if adm.Username == username {
administrator = adm
break
}
}

return administrator, true, nil
}
Loading

0 comments on commit 7717776

Please sign in to comment.