69 Commits

Author SHA1 Message Date
a6794cdfed feat(deps): update go compiler to 1.24
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m35s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m16s
2025-03-04 12:17:17 +01:00
38b3ad9326 fix(deps): update module golang.org/x/net to v0.35.0
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m4s
2025-03-03 10:02:17 +00:00
a6f5710521 chore(deps): update node.js to v22.14.0
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-03-03 09:59:59 +00:00
cb0252e1af chore(deps): update tailwindcss monorepo to v4.0.9
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 2m48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-03-03 00:06:37 +00:00
f2937a762e chore(deps): update debian:12.9 docker digest to 3528682
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m28s
2025-03-02 18:24:21 +00:00
60daac48b4 fix(deps): update module github.com/prometheus/client_golang to v1.21.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 11m2s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m24s
2025-02-24 18:28:38 +00:00
b2a655f73a chore(deps): update tailwindcss monorepo to v4.0.8
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 9m58s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 10m23s
2025-02-24 16:18:54 +00:00
663081d719 chore(deps): update node.js to 5145c88
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m28s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m39s
2025-02-24 12:34:43 +00:00
28460a6bac fix: make tests more resilient
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m15s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 8m28s
2025-02-24 12:58:48 +01:00
3039d66295 feat(docs): update readme
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 2m54s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 2m16s
2025-02-23 21:46:31 +01:00
9b96e8f0a5 chore(deps): update debian:12.9 docker digest to 4abf773
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m6s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m58s
2025-02-11 14:09:06 +00:00
b86b737a82 chore(deps): update golang:1.23.5 docker digest to e213430
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 6m58s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m14s
2025-02-09 00:11:23 +00:00
f2951985c2 fix(deps): migrate tailwindcss to v4 and remove daisyui
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m37s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m47s
2025-02-03 23:18:51 +01:00
a88ed4bb47 fix(deps): update module github.com/golang-migrate/migrate/v4 to v4.18.2
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 56s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m37s
2025-02-02 10:14:35 +00:00
7ac910aec6 fix(deps): update module github.com/a-h/templ to v0.3.833
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m53s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m3s
2025-02-02 00:07:44 +00:00
15ccd4ef01 chore(deps): update node.js to v22.13.1
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m2s
2025-01-26 01:10:32 +01:00
54f8082430 chore(deps): update golang:1.23.5 docker digest to 8c10f21
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 1m55s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-01-26 00:05:02 +00:00
0d5143b91b chore(deps): update golang docker tag to v1.23.5
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2025-01-20 04:52:37 +01:00
3d094154ce chore(deps): update debian docker tag to v12.9
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 49s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 54s
2025-01-19 21:03:17 +00:00
3d1111256c chore(deps): update golang:1.23.4 docker digest to 9820aca
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m2s
2025-01-19 20:38:44 +01:00
bc82ad123b chore(deps): update dependency go to v1.23.5
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2025-01-19 01:11:32 +01:00
cb01d5e0d4 chore(deps): update node.js to fa54405
Some checks are pending
Build Docker Image / Build-Docker-Image (push) Successful in 49s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Waiting to run
2025-01-19 00:05:01 +00:00
7cb46aad36 chore(deps): update node.js to 816f04d
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m50s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 53s
2025-01-14 20:21:01 +00:00
92bb836e87 chore(deps): update node.js to v22.13.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 49s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 53s
2025-01-08 23:05:17 +00:00
1d89f45ff9 fix(deps): update module golang.org/x/net to v0.34.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2025-01-07 23:06:18 +00:00
bc70babaca fix(deps): update module golang.org/x/crypto to v0.32.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m20s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 54s
2025-01-06 23:04:59 +00:00
d3700d5a3b fix(deps): update module github.com/a-h/templ to v0.3.819
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m56s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 55s
2025-01-02 23:05:26 +00:00
9a8dfc96db chore: #174 update readme
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 57s
2024-12-31 13:23:01 +01:00
52f6d3d706 chore: #174 make into template
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
2024-12-31 12:25:30 +01:00
508aa3038b feat(observability): #360 remove umami to reduce complexity
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 53s
2024-12-31 12:03:59 +01:00
0b155af4c9 chore(deps): update dependency daisyui to v4.12.23
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 53s
2024-12-27 00:08:13 +01:00
917218da82 chore(deps): update golang:1.23.4 docker digest to 7ea4c9d
Some checks are pending
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Waiting to run
2024-12-26 23:02:20 +00:00
fe7f01e035 chore(deps): update node.js to 0e910f4
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2024-12-25 23:02:10 +00:00
55408da398 chore(auth): #331 add and fix forgot password actual tests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2024-12-25 23:13:58 +01:00
b0f183aeed chore(auth): #331 add and fix forgot password tests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m9s
2024-12-25 22:58:37 +01:00
42a910df4b chore(deps): update node.js to 7bea049
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2024-12-25 22:26:02 +01:00
73333256c5 chore(deps): update golang:1.23.4 docker digest to b01f7c7
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2024-12-25 21:21:30 +00:00
14b477f560 chore(auth): #331 add change password tests
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2024-12-25 22:20:52 +01:00
87188724ac chore(deps): update debian:12.8 docker digest to b877a1a
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 54s
2024-12-25 20:58:34 +00:00
5ea400352f chore(auth): #331 add sign up verify tests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 53s
2024-12-25 21:56:32 +01:00
397442767a chore(auth): #331 implement and fix sign up tests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2024-12-24 22:39:26 +01:00
9462f8b245 chore(auth): #331 implement and fix fist sign up tests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 45s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2024-12-23 22:57:41 +01:00
7a7d7cf204 chore(auth): #331 remove duplicated/outdated tests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 45s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2024-12-23 22:36:55 +01:00
96b4cc6889 chore(auth): #331 add tests for sign in
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 45s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 54s
2024-12-23 22:33:10 +01:00
7a9d34d464 chore(auth): #331 add tests for sign in
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 45s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 53s
2024-12-23 13:56:41 +01:00
52cd85d904 chore(auth): #331 add tests for sign out
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 50s
Build Docker Image / Build-Docker-Image (push) Successful in 46s
2024-12-22 23:48:53 +01:00
fb6cc0acda chore(auth): #331 add tests for delete account
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 46s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2024-12-22 23:07:15 +01:00
6a551929c5 chore(auth): #331 add and fix session tests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 45s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 48s
2024-12-22 22:33:17 +01:00
ea653f0087 chore(auth): #331 unify existing tests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2024-12-22 21:31:19 +01:00
143662fff0 fix(deps): update module golang.org/x/net to v0.33.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 52s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 49s
2024-12-18 23:01:53 +00:00
fdb955f20c feat: #337 unify types for auth module
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 43s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 49s
2024-12-18 23:44:59 +01:00
dcc5207272 feat(security): #328 delete old sessions for change and forgot password
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 43s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 50s
2024-12-18 22:56:51 +01:00
43d0a3d022 chore(test): add test for cache control and security headers 2024-12-18 22:56:48 +01:00
c48194c36f chore(deps): update dependency tailwindcss to v3.4.17
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 43s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 50s
2024-12-17 23:02:08 +00:00
23aa3d4b0e chore(test): add test for cache control and security headers
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 43s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 49s
2024-12-16 23:04:22 +01:00
9bb603970d chore(test): fix integration test 'waitForReady'
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 43s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 51s
2024-12-16 22:38:59 +01:00
6d3902e572 chore(test): update integration test setup to automatically generate ports
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 43s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2024-12-16 17:30:33 +01:00
88892ab6ca chore(deps): update dependency htmx.org to v2.0.4
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 44s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 48s
2024-12-13 19:01:26 +00:00
28a97414d4 chore(deps): update dependency daisyui to v4.12.22
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 46s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m8s
2024-12-13 02:02:37 +00:00
f0ec293be8 feat(security): #314 include hsts
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 42s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 48s
2024-12-12 21:50:32 +01:00
1ad694ce2b feat(security): #314 include all proposed security headers
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 43s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 47s
2024-12-12 21:37:23 +01:00
60fe2789cc feat(security): #312 disable autofill for PII information
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 49s
2024-12-12 21:03:14 +01:00
5d83c9dcc0 chore(deps): update dependency daisyui to v4.12.21
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 44s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 49s
2024-12-12 16:01:32 +00:00
a8937a0e64 chore(deps): update golang:1.23.4 docker digest to 7003184
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 44s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 50s
2024-12-12 01:01:46 +00:00
9629e71962 fix(deps): update module golang.org/x/net to v0.32.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 44s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 52s
2024-12-12 00:01:42 +00:00
380dd979f6 feat(security): #305 don't cache sensitive data
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 44s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 47s
2024-12-12 00:02:55 +01:00
e81fa4b2b6 fix(deps): update module golang.org/x/crypto to v0.31.0
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 49s
2024-12-11 23:53:04 +01:00
5579e5da0c chore(deps): update dependency daisyui to v4.12.20
Some checks are pending
Build Docker Image / Build-Docker-Image (push) Successful in 45s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Waiting to run
2024-12-11 22:48:00 +00:00
12d7c13b02 feat(security): #286 use csrf token for delete request
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 45s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 50s
2024-12-11 15:49:11 +01:00
52 changed files with 3345 additions and 2200 deletions

View File

@@ -11,5 +11,5 @@ jobs:
steps:
- name: Check out repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: docker build . -t me-fit-test
- run: docker rmi me-fit-test
- run: docker build . -t web-app-template-test
- run: docker rmi web-app-template-test

View File

@@ -11,8 +11,8 @@ jobs:
- name: Check out repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
- run: docker build . -t git.wundenbergs.de/x/me-fit:latest -t git.wundenbergs.de/x/me-fit:$GITHUB_SHA
- run: docker push git.wundenbergs.de/x/me-fit:latest
- run: docker push git.wundenbergs.de/x/me-fit:$GITHUB_SHA
- run: docker rmi git.wundenbergs.de/x/me-fit:latest git.wundenbergs.de/x/me-fit:$GITHUB_SHA
- run: docker build . -t git.wundenbergs.de/x/web-app-template:latest -t git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
- run: docker push git.wundenbergs.de/x/web-app-template:latest
- run: docker push git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
- run: docker rmi git.wundenbergs.de/x/web-app-template:latest git.wundenbergs.de/x/web-app-template:$GITHUB_SHA

View File

@@ -3,11 +3,11 @@ dir: mocks/
outpkg: mocks
issue-845-fix: True
packages:
me-fit/service:
web-app-template/service:
interfaces:
Random:
Clock:
Mail:
me-fit/db:
web-app-template/db:
interfaces:
Auth:

View File

@@ -1,6 +1,6 @@
FROM golang:1.23.4@sha256:574185e5c6b9d09873f455a7c205ea0514bfd99738c5dc7750196403a44ed4b7 AS builder_go
WORKDIR /me-fit
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.62.2
FROM golang:1.24.0 AS builder_go
WORKDIR /web-app-template
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
RUN go install github.com/a-h/templ/cmd/templ@latest
RUN go install github.com/vektra/mockery/v2@latest
COPY go.mod go.sum ./
@@ -10,23 +10,23 @@ RUN templ generate
RUN mockery --log-level warn
RUN go test ./...
RUN golangci-lint run ./...
RUN go build -o /me-fit/me-fit .
RUN go build -o /web-app-template/web-app-template .
FROM node:22.12.0@sha256:35a5dd72bcac4bce43266408b58a02be6ff0b6098ffa6f5435aeea980a8951d7 AS builder_node
WORKDIR /me-fit
FROM node:22.14.0@sha256:f6b9c31ace05502dd98ef777aaa20464362435dcc5e312b0e213121dcf7d8b95 AS builder_node
WORKDIR /web-app-template
COPY package.json package-lock.json ./
RUN npm clean-install
COPY . ./
RUN npm run build
FROM debian:12.8@sha256:17122fe3d66916e55c0cbd5bbf54bb3f87b3582f4d86a755a0fd3498d360f91b
WORKDIR /me-fit
FROM debian:12.9@sha256:35286826a88dc879b4f438b645ba574a55a14187b483d09213a024dc0c0a64ed
WORKDIR /web-app-template
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
COPY migration ./migration
COPY --from=builder_go /me-fit/me-fit ./me-fit
COPY --from=builder_node /me-fit/static ./static
COPY --from=builder_go /web-app-template/web-app-template ./web-app-template
COPY --from=builder_node /web-app-template/static ./static
EXPOSE 8080
ENTRYPOINT ["/me-fit/me-fit"]
ENTRYPOINT ["/web-app-template/web-app-template"]

View File

@@ -1,44 +1,98 @@
# stackFAST
# Web-App-Template
Your (almost) independent tech stack to host on a VPC.
A basic template with authentication to easily host on a VPC.
## Features
stackFAST includes everything you need to build your App. Focus yourself on developing your idea, instead of "wasting" time on things like setting up auth and observability. This blueprint tries to include as much as possible, but still keep it simple.
This template includes everything essential to build an app. It includes the following features:
The blueprint contains the following features:
- Authentication: Users can login, logout, register and reset their password. For increased security TOTP is available aswell.
- Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone. For web analytics umami is included, which is an lighweight self hosted alternative to google analytics.
- Authentication: Users can login, logout, register and reset their password. (for increased security TOTP is planned aswell.)
- Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone.
- Mail: You are able to send mail with SMTP. You still need an external Mail Server, but a guide on how to set that up with a custom domain is included.
- SSL: This is included by using traefik as reverse proxy. It handles SSL certificates automatically. Furthermore all services are accessible through subdomains. Best thing is, you can add your more with 3 lines of code
- Actual Stack: SSG SvelteKit + Tailwindcss + DaisyUI + GO Backend for easy and fast feature development
- SSL: This is included by using traefik as reverse proxy. It handles SSL certificates automatically. Furthermore all services are accessible through subdomains.
- Stack: Tailwindcss + HTMX + GO Backend with templ and sqlite
## Architecture Design Decisions
### Authentication
Authentication is a broad topic. Many people think you should not consider implementing authentication yourself. On the other hand, experts at OWASP don't recommend this in their cheat sheet on that topic. I'm going to explain my criterions and afterwards take a decision.
Authentication is a broad topic. Many people think you should not consider implementing authentication yourself. On the other hand, If only security experts are allowed to write software, what does that result in? I'm going to explain my criterions and afterwards take a decision.
There are a few restrictions I would like to contain:
- I want this blueprint do as much as as possible without relying on external services. This way the things needs to be done on other website are very minimal. Furthermore I would like to take back privacy from BigTech.
- I think most cloud services are overpriced. I want to provide an alternative approach with self holsting. But I don't like the idea to spin up 30 services for a small app with 0 users. It should still be possible to run on a small VPC (2vcpu, 2GB).
- I want this template do as much as as possible without relying on external services. This way the setup cost and dependencies can be minimized.
- It should still be possible to run on a small VPC (2vcpu, 2GB).
- It should be as secure as possible
As of 2024 there are 4 options:
- Implement the authentication myself: If I'm holding thight to the cheat sheet, I "should" be able to doge "most" security risks and attacks according to this topic. Unfortanatly I'm not an expert in this field and will do some errors. If people will buy this blueprint, I probably can't sleep well. Especially if real users start using it. At least this has the advantage of not adding adittional services or configuration to the project.
- Using OAuth2 with Google and Apple: Using OAuth2 is the standard for secure applications. Google and Apple has their experts. They deal with attacks every hour of the day. This has the advantage, that users don't have to create new credentials. The only disatvantage is my personal hate on big tech.
- Using OAuth2 with Keycloak: Same as above, just that the OAuth2 endpoint is another self hosted service. The only advantage is, it's not proprietary and self hosted. But users are not used to get redirected to a key cloak on sign up. They are used to sign in with Google though. Furthermore Google et. al are protecting themselves against credential stuffing attacks etc.
- Firebase, Clerk, etc.: Users have to sign up again AND blueprint users have to setup another project.
I determined 4 options:
1. Implement the authentication myself
2. Using OAuth2 with Keycloak
3. Using OAuth2 with Google and Apple
4. Firebase, Clerk, etc.
Even though I would really implement authentication myself, I think OAuth2 with external providers is the best bet. Especially because my reasoning is privacy, which most people just don't care about enough. Using this approach, adding in a keycloak is possible without breaking changes at a later point, as long as I keep the Google Sign In.
#### 1. Implement the authentication myself
It's always possible to implement it myself. The topic of authentication is something special though.
Pros:
- Great Cheat cheets from OWASP
- No adittional configuration or services needed
- Great learning experience on the topic "security"
Cons:
- Great attack vector
- Introcution of vlunerabillities is possible
- No DDOS protection
#### 2. Using OAuth2 with Google and Apple
Instead of implementing authentication from scratch, an external OAuth2 provider is embedded into the application.
Pros:
- The Systems of BigTech are probably safer. They have security experts employed.
- The other external system is responsible to prevent credential stuffing attacks, etc.
- Users don't have to create new credentials
Cons:
- High dependency on those providers
- Single Point of failure (If your account is banned, your application access get's lost as well.)
- It's possible that these providers ban the whole application (All users lose access)
- There still needs to be implemented some logic
- Full application integration can be difficult
#### 3. Using OAuth2 with Keycloak
This option is almost identical with the previois one, but the provider is self hosted.
Pros:
- Indipendent from 3rd party providers
- The credentials are stored safly
Cons:
- Self hosted (no DDOS protection, etc.)
- There still needs to be implemented some logic server side
- Full application integration can be difficult
#### 4. Firebase, Clerk, etc.
Users can sign in with a seperate sdk on your website
Pros:
- Safe and Sound authentication
Cons:
- Dependent on those providers / adittional setup needed
- Application can be banned
- Still some integration code needed
#### Decision
I've decided on implementing authentication myself, as this is a great learning opportunity. It may not be as secure as other solutions, but if I keep tighly to the OWASP recommendations, it should should good enough.
### Email
For Email verification, etc. a mail server is needed, that can send a whole lot of mails. Aditionally, a mail account is needed for incoming mails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails. Thus, the first external service is needed.
For Email verification, etc. a mail server is needed, that can send a whole lot of mails. Aditionally, a mail account is needed for incoming emails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails.
In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. You are free to choose a transactional mail provider. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails. Most provider provide 100 mails / day for free.
In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails.

View File

@@ -1,8 +1,8 @@
package db
import (
"me-fit/log"
"me-fit/types"
"web-app-template/log"
"web-app-template/types"
"database/sql"
"errors"
@@ -17,89 +17,22 @@ var (
ErrAlreadyExists = errors.New("row already exists")
)
type User struct {
Id uuid.UUID
Email string
EmailVerified bool
EmailVerifiedAt *time.Time
IsAdmin bool
Password []byte
Salt []byte
CreateAt time.Time
}
func NewUser(id uuid.UUID, email string, emailVerified bool, emailVerifiedAt *time.Time, isAdmin bool, password []byte, salt []byte, createAt time.Time) *User {
return &User{
Id: id,
Email: email,
EmailVerified: emailVerified,
EmailVerifiedAt: emailVerifiedAt,
IsAdmin: isAdmin,
Password: password,
Salt: salt,
CreateAt: createAt,
}
}
type Session struct {
Id string
UserId uuid.UUID
CreatedAt time.Time
ExpiresAt time.Time
}
func NewSession(id string, userId uuid.UUID, createdAt time.Time, expiresAt time.Time) *Session {
return &Session{
Id: id,
UserId: userId,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
}
}
type Token struct {
UserId uuid.UUID
SessionId string
Token string
Type TokenType
CreatedAt time.Time
ExpiresAt time.Time
}
type TokenType string
var (
TokenTypeEmailVerify TokenType = "email_verify"
TokenTypePasswordReset TokenType = "password_reset"
TokenTypeCsrf TokenType = "csrf"
)
func NewToken(userId uuid.UUID, sessionId string, token string, tokenType TokenType, createdAt time.Time, expiresAt time.Time) *Token {
return &Token{
UserId: userId,
SessionId: sessionId,
Token: token,
Type: tokenType,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
}
}
type Auth interface {
InsertUser(user *User) error
UpdateUser(user *User) error
GetUserByEmail(email string) (*User, error)
GetUser(userId uuid.UUID) (*User, error)
InsertUser(user *types.User) error
UpdateUser(user *types.User) error
GetUserByEmail(email string) (*types.User, error)
GetUser(userId uuid.UUID) (*types.User, error)
DeleteUser(userId uuid.UUID) error
InsertToken(token *Token) error
GetToken(token string) (*Token, error)
GetTokensByUserIdAndType(userId uuid.UUID, tokenType TokenType) ([]*Token, error)
GetTokensBySessionIdAndType(sessionId string, tokenType TokenType) ([]*Token, error)
InsertToken(token *types.Token) error
GetToken(token string) (*types.Token, error)
GetTokensByUserIdAndType(userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error)
GetTokensBySessionIdAndType(sessionId string, tokenType types.TokenType) ([]*types.Token, error)
DeleteToken(token string) error
InsertSession(session *Session) error
GetSession(sessionId string) (*Session, error)
InsertSession(session *types.Session) error
GetSession(sessionId string) (*types.Session, error)
GetSessions(userId uuid.UUID) ([]*types.Session, error)
DeleteSession(sessionId string) error
DeleteOldSessions(userId uuid.UUID) error
}
@@ -112,7 +45,7 @@ func NewAuthSqlite(db *sql.DB) *AuthSqlite {
return &AuthSqlite{db: db}
}
func (db AuthSqlite) InsertUser(user *User) error {
func (db AuthSqlite) InsertUser(user *types.User) error {
_, err := db.db.Exec(`
INSERT INTO user (user_id, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -130,7 +63,7 @@ func (db AuthSqlite) InsertUser(user *User) error {
return nil
}
func (db AuthSqlite) UpdateUser(user *User) error {
func (db AuthSqlite) UpdateUser(user *types.User) error {
_, err := db.db.Exec(`
UPDATE user
SET email_verified = ?, email_verified_at = ?, password = ?
@@ -145,7 +78,7 @@ func (db AuthSqlite) UpdateUser(user *User) error {
return nil
}
func (db AuthSqlite) GetUserByEmail(email string) (*User, error) {
func (db AuthSqlite) GetUserByEmail(email string) (*types.User, error) {
var (
userId uuid.UUID
emailVerified bool
@@ -169,10 +102,10 @@ func (db AuthSqlite) GetUserByEmail(email string) (*User, error) {
}
}
return NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
}
func (db AuthSqlite) GetUser(userId uuid.UUID) (*User, error) {
func (db AuthSqlite) GetUser(userId uuid.UUID) (*types.User, error) {
var (
email string
emailVerified bool
@@ -196,7 +129,7 @@ func (db AuthSqlite) GetUser(userId uuid.UUID) (*User, error) {
}
}
return NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
}
func (db AuthSqlite) DeleteUser(userId uuid.UUID) error {
@@ -244,7 +177,7 @@ func (db AuthSqlite) DeleteUser(userId uuid.UUID) error {
return nil
}
func (db AuthSqlite) InsertToken(token *Token) error {
func (db AuthSqlite) InsertToken(token *types.Token) error {
_, err := db.db.Exec(`
INSERT INTO token (user_id, session_id, type, token, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, token.UserId, token.SessionId, token.Type, token.Token, token.CreatedAt, token.ExpiresAt)
@@ -257,11 +190,11 @@ func (db AuthSqlite) InsertToken(token *Token) error {
return nil
}
func (db AuthSqlite) GetToken(token string) (*Token, error) {
func (db AuthSqlite) GetToken(token string) (*types.Token, error) {
var (
userId uuid.UUID
sessionId string
tokenType TokenType
tokenType types.TokenType
createdAtStr string
expiresAtStr string
createdAt time.Time
@@ -295,10 +228,10 @@ func (db AuthSqlite) GetToken(token string) (*Token, error) {
return nil, types.ErrInternal
}
return NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil
return types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil
}
func (db AuthSqlite) GetTokensByUserIdAndType(userId uuid.UUID, tokenType TokenType) ([]*Token, error) {
func (db AuthSqlite) GetTokensByUserIdAndType(userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.Query(`
SELECT token, created_at, expires_at
@@ -314,7 +247,7 @@ func (db AuthSqlite) GetTokensByUserIdAndType(userId uuid.UUID, tokenType TokenT
return getTokensFromQuery(query, userId, "", tokenType)
}
func (db AuthSqlite) GetTokensBySessionIdAndType(sessionId string, tokenType TokenType) ([]*Token, error) {
func (db AuthSqlite) GetTokensBySessionIdAndType(sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.Query(`
SELECT token, created_at, expires_at
@@ -330,8 +263,8 @@ func (db AuthSqlite) GetTokensBySessionIdAndType(sessionId string, tokenType Tok
return getTokensFromQuery(query, uuid.Nil, sessionId, tokenType)
}
func getTokensFromQuery(query *sql.Rows, userId uuid.UUID, sessionId string, tokenType TokenType) ([]*Token, error) {
var tokens []*Token
func getTokensFromQuery(query *sql.Rows, userId uuid.UUID, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
var tokens []*types.Token
hasRows := false
for query.Next() {
@@ -363,7 +296,7 @@ func getTokensFromQuery(query *sql.Rows, userId uuid.UUID, sessionId string, tok
return nil, types.ErrInternal
}
tokens = append(tokens, NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt))
tokens = append(tokens, types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt))
}
if !hasRows {
@@ -382,7 +315,7 @@ func (db AuthSqlite) DeleteToken(token string) error {
return nil
}
func (db AuthSqlite) InsertSession(session *Session) error {
func (db AuthSqlite) InsertSession(session *types.Session) error {
_, err := db.db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
@@ -396,7 +329,7 @@ func (db AuthSqlite) InsertSession(session *Session) error {
return nil
}
func (db AuthSqlite) GetSession(sessionId string) (*Session, error) {
func (db AuthSqlite) GetSession(sessionId string) (*types.Session, error) {
var (
userId uuid.UUID
@@ -410,15 +343,51 @@ func (db AuthSqlite) GetSession(sessionId string) (*Session, error) {
WHERE session_id = ?`, sessionId).Scan(&userId, &createdAt, &expiresAt)
if err != nil {
log.Warn("Session not found: %v", err)
return nil, ErrNotFound
}
return NewSession(sessionId, userId, createdAt, expiresAt), nil
return types.NewSession(sessionId, userId, createdAt, expiresAt), nil
}
func (db AuthSqlite) GetSessions(userId uuid.UUID) ([]*types.Session, error) {
sessions, err := db.db.Query(`
SELECT session_id, created_at, expires_at
FROM session
WHERE user_id = ?`, userId)
if err != nil {
log.Error("Could not get sessions: %v", err)
return nil, types.ErrInternal
}
var result []*types.Session
for sessions.Next() {
var (
sessionId string
createdAt time.Time
expiresAt time.Time
)
err := sessions.Scan(&sessionId, &createdAt, &expiresAt)
if err != nil {
log.Error("Could not scan session: %v", err)
return nil, types.ErrInternal
}
session := types.NewSession(sessionId, userId, createdAt, expiresAt)
result = append(result, session)
}
return result, nil
}
func (db AuthSqlite) DeleteOldSessions(userId uuid.UUID) error {
// Delete old inactive sessions
_, err := db.db.Exec("DELETE FROM session WHERE created_at < datetime('now','-8 hours') AND user_id = ?", userId)
_, err := db.db.Exec(`
DELETE FROM session
WHERE expires_at < datetime('now')
AND user_id = ?`, userId)
if err != nil {
log.Error("Could not delete old sessions: %v", err)
return types.ErrInternal
@@ -428,7 +397,6 @@ func (db AuthSqlite) DeleteOldSessions(userId uuid.UUID) error {
func (db AuthSqlite) DeleteSession(sessionId string) error {
if sessionId != "" {
_, err := db.db.Exec("DELETE FROM session WHERE session_id = ?", sessionId)
if err != nil {
log.Error("Could not delete session: %v", err)

View File

@@ -2,7 +2,7 @@ package db
import (
"database/sql"
"me-fit/types"
"web-app-template/types"
"testing"
"time"
@@ -38,7 +38,7 @@ func TestUser(t *testing.T) {
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expected := NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
expected := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(expected)
assert.Nil(t, err)
@@ -68,7 +68,7 @@ func TestUser(t *testing.T) {
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
user := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(user)
assert.Nil(t, err)
@@ -83,7 +83,7 @@ func TestUser(t *testing.T) {
underTest := AuthSqlite{db: db}
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
user := types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
err := underTest.InsertUser(user)
assert.Equal(t, types.ErrInternal, err)
@@ -101,7 +101,7 @@ func TestToken(t *testing.T) {
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expiresAt := createAt.Add(24 * time.Hour)
expected := NewToken(uuid.New(), "sessionId", "token", TokenTypeCsrf, createAt, expiresAt)
expected := types.NewToken(uuid.New(), "sessionId", "token", types.TokenTypeCsrf, createAt, expiresAt)
err := underTest.InsertToken(expected)
assert.Nil(t, err)
@@ -113,13 +113,13 @@ func TestToken(t *testing.T) {
expected.SessionId = ""
actuals, err := underTest.GetTokensByUserIdAndType(expected.UserId, expected.Type)
assert.Nil(t, err)
assert.Equal(t, []*Token{expected}, actuals)
assert.Equal(t, []*types.Token{expected}, actuals)
expected.SessionId = "sessionId"
expected.UserId = uuid.Nil
actuals, err = underTest.GetTokensBySessionIdAndType(expected.SessionId, expected.Type)
assert.Nil(t, err)
assert.Equal(t, []*Token{expected}, actuals)
assert.Equal(t, []*types.Token{expected}, actuals)
})
t.Run("should insert and return multiple tokens", func(t *testing.T) {
t.Parallel()
@@ -130,8 +130,8 @@ func TestToken(t *testing.T) {
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expiresAt := createAt.Add(24 * time.Hour)
userId := uuid.New()
expected1 := NewToken(userId, "sessionId", "token1", TokenTypeCsrf, createAt, expiresAt)
expected2 := NewToken(userId, "sessionId", "token2", TokenTypeCsrf, createAt, expiresAt)
expected1 := types.NewToken(userId, "sessionId", "token1", types.TokenTypeCsrf, createAt, expiresAt)
expected2 := types.NewToken(userId, "sessionId", "token2", types.TokenTypeCsrf, createAt, expiresAt)
err := underTest.InsertToken(expected1)
assert.Nil(t, err)
@@ -142,7 +142,7 @@ func TestToken(t *testing.T) {
expected2.UserId = uuid.Nil
actuals, err := underTest.GetTokensBySessionIdAndType(expected1.SessionId, expected1.Type)
assert.Nil(t, err)
assert.Equal(t, []*Token{expected1, expected2}, actuals)
assert.Equal(t, []*types.Token{expected1, expected2}, actuals)
expected1.SessionId = ""
expected2.SessionId = ""
@@ -150,7 +150,7 @@ func TestToken(t *testing.T) {
expected2.UserId = userId
actuals, err = underTest.GetTokensByUserIdAndType(userId, expected1.Type)
assert.Nil(t, err)
assert.Equal(t, []*Token{expected1, expected2}, actuals)
assert.Equal(t, []*types.Token{expected1, expected2}, actuals)
})
t.Run("should return ErrNotFound", func(t *testing.T) {
@@ -162,10 +162,10 @@ func TestToken(t *testing.T) {
_, err := underTest.GetToken("nonExistent")
assert.Equal(t, ErrNotFound, err)
_, err = underTest.GetTokensByUserIdAndType(uuid.New(), TokenTypeEmailVerify)
_, err = underTest.GetTokensByUserIdAndType(uuid.New(), types.TokenTypeEmailVerify)
assert.Equal(t, ErrNotFound, err)
_, err = underTest.GetTokensBySessionIdAndType("sessionId", TokenTypeEmailVerify)
_, err = underTest.GetTokensBySessionIdAndType("sessionId", types.TokenTypeEmailVerify)
assert.Equal(t, ErrNotFound, err)
})
t.Run("should return ErrAlreadyExists", func(t *testing.T) {
@@ -176,7 +176,7 @@ func TestToken(t *testing.T) {
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
user := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(user)
assert.Nil(t, err)
@@ -191,7 +191,7 @@ func TestToken(t *testing.T) {
underTest := AuthSqlite{db: db}
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
user := types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
err := underTest.InsertUser(user)
assert.Equal(t, types.ErrInternal, err)

View File

@@ -1,8 +1,8 @@
package db
import (
"me-fit/log"
"me-fit/types"
"web-app-template/log"
"web-app-template/types"
"database/sql"
"errors"

View File

@@ -1,8 +1,8 @@
package db
import (
"me-fit/log"
"me-fit/types"
"web-app-template/log"
"web-app-template/types"
"database/sql"
"errors"

24
go.mod
View File

@@ -1,17 +1,19 @@
module me-fit
module web-app-template
go 1.22.5
go 1.23
toolchain go1.23.5
require (
github.com/a-h/templ v0.2.793
github.com/golang-migrate/migrate/v4 v4.18.1
github.com/a-h/templ v0.3.833
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/prometheus/client_golang v1.20.5
github.com/prometheus/client_golang v1.21.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.30.0
golang.org/x/net v0.29.0
golang.org/x/crypto v0.33.0
golang.org/x/net v0.35.0
)
require (
@@ -20,15 +22,15 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.28.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
golang.org/x/sys v0.30.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

50
go.sum
View File

@@ -1,13 +1,15 @@
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -17,36 +19,48 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA=
github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,12 +1,12 @@
package handler
import (
"me-fit/handler/middleware"
"me-fit/log"
"me-fit/service"
"me-fit/template/auth"
"me-fit/types"
"me-fit/utils"
"web-app-template/handler/middleware"
"web-app-template/log"
"web-app-template/service"
"web-app-template/template/auth"
"web-app-template/types"
"web-app-template/utils"
"errors"
"net/http"
@@ -31,8 +31,8 @@ func NewAuth(service service.Auth, render *Render) Auth {
}
func (handler AuthImpl) Handle(router *http.ServeMux) {
router.Handle("/auth/signin", handler.handleSignInPage())
router.Handle("/api/auth/signin", handler.handleSignIn())
router.Handle("GET /auth/signin", handler.handleSignInPage())
router.Handle("POST /api/auth/signin", handler.handleSignIn())
router.Handle("/auth/signup", handler.handleSignUpPage())
router.Handle("/auth/verify", handler.handleSignUpVerifyPage())
@@ -40,17 +40,17 @@ func (handler AuthImpl) Handle(router *http.ServeMux) {
router.Handle("/auth/verify-email", handler.handleSignUpVerifyResponsePage())
router.Handle("/api/auth/signup", handler.handleSignUp())
router.Handle("/api/auth/signout", handler.handleSignOut())
router.Handle("POST /api/auth/signout", handler.handleSignOut())
router.Handle("/auth/delete-account", handler.handleDeleteAccountPage())
router.Handle("/api/auth/delete-account", handler.handleDeleteAccountComp())
router.Handle("/auth/change-password", handler.handleChangePasswordPage())
router.Handle("/api/auth/change-password", handler.handleChangePasswordComp())
router.Handle("GET /auth/change-password", handler.handleChangePasswordPage())
router.Handle("POST /api/auth/change-password", handler.handleChangePasswordComp())
router.Handle("/auth/reset-password", handler.handleResetPasswordPage())
router.Handle("/api/auth/reset-password", handler.handleForgotPasswordComp())
router.Handle("/api/auth/reset-password-actual", handler.handleForgotPasswordResponseComp())
router.Handle("GET /auth/forgot-password", handler.handleForgotPasswordPage())
router.Handle("POST /api/auth/forgot-password", handler.handleForgotPasswordComp())
router.Handle("POST /api/auth/forgot-password-actual", handler.handleForgotPasswordResponseComp())
}
var (
@@ -77,37 +77,28 @@ func (handler AuthImpl) handleSignInPage() http.HandlerFunc {
func (handler AuthImpl) handleSignIn() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*service.User, error) {
var email = r.FormValue("email")
var password = r.FormValue("password")
session, err := handler.service.SignIn(email, password)
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*types.User, error) {
session := middleware.GetSession(r)
email := r.FormValue("email")
password := r.FormValue("password")
session, user, err := handler.service.SignIn(session, email, password)
if err != nil {
return nil, err
}
cookie := http.Cookie{
Name: "id",
Value: session.Id,
MaxAge: 60 * 60 * 8, // 8 hours
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
}
cookie := middleware.CreateSessionCookie(session.Id)
http.SetCookie(w, &cookie)
return session.User, nil
return user, nil
})
if err != nil {
if err == service.ErrInvaidCredentials {
utils.TriggerToast(w, r, "error", "Invalid email or password")
http.Error(w, "Invalid email or password", http.StatusUnauthorized)
if err == service.ErrInvalidCredentials {
utils.TriggerToast(w, r, "error", "Invalid email or password", http.StatusUnauthorized)
} else {
log.Error("Error signing in: %v", err)
http.Error(w, "An error occurred", http.StatusInternalServerError)
utils.TriggerToast(w, r, "error", "An error occurred", http.StatusInternalServerError)
}
return
}
@@ -180,11 +171,17 @@ func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
err := handler.service.VerifyUserEmail(token)
if err != nil {
utils.DoRedirect(w, r, "/auth/signin")
isVerified := err == nil
comp := auth.VerifyResponseComp(isVerified)
var status int
if isVerified {
status = http.StatusOK
} else {
utils.DoRedirect(w, r, "/")
status = http.StatusBadRequest
}
handler.render.RenderLayoutWithStatus(r, w, comp, nil, status)
}
}
@@ -207,16 +204,19 @@ func (handler AuthImpl) handleSignUp() http.HandlerFunc {
if err != nil {
if errors.Is(err, types.ErrInternal) {
utils.TriggerToast(w, r, "error", "An error occurred")
utils.TriggerToast(w, r, "error", "An error occurred", http.StatusInternalServerError)
return
} else if errors.Is(err, service.ErrInvalidEmail) {
utils.TriggerToast(w, r, "error", "The email provided is invalid")
utils.TriggerToast(w, r, "error", "The email provided is invalid", http.StatusBadRequest)
return
} else if errors.Is(err, service.ErrInvalidPassword) {
utils.TriggerToast(w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
return
}
// If the "service.ErrAccountExists", then just continue
// If err is "service.ErrAccountExists", then just continue
}
utils.TriggerToast(w, r, "success", "A link to activate your account has been emailed to the address provided.")
utils.TriggerToast(w, r, "success", "An activation link has been send to your email", http.StatusOK)
}
}
@@ -270,15 +270,13 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
password := r.FormValue("password")
_, err := handler.service.SignIn(user.Email, password)
err := handler.service.DeleteAccount(user, password)
if err != nil {
utils.TriggerToast(w, r, "error", "Password not correct")
return
}
err = handler.service.DeleteAccount(user)
if err != nil {
utils.TriggerToast(w, r, "error", "Internal Server Error")
if err == service.ErrInvalidCredentials {
utils.TriggerToast(w, r, "error", "Password not correct", http.StatusBadRequest)
} else {
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
}
return
}
@@ -306,31 +304,32 @@ func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc {
func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := middleware.GetSession(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
if session == nil || user == nil {
utils.TriggerToast(w, r, "error", "Unathorized", http.StatusUnauthorized)
return
}
currPass := r.FormValue("current-password")
newPass := r.FormValue("new-password")
err := handler.service.ChangePassword(user, currPass, newPass)
err := handler.service.ChangePassword(user, session.Id, currPass, newPass)
if err != nil {
utils.TriggerToast(w, r, "error", "Password not correct")
utils.TriggerToast(w, r, "error", "Password not correct", http.StatusBadRequest)
return
}
utils.TriggerToast(w, r, "success", "Password changed")
utils.TriggerToast(w, r, "success", "Password changed", http.StatusOK)
}
}
func (handler AuthImpl) handleResetPasswordPage() http.HandlerFunc {
func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
if user != nil {
utils.DoRedirect(w, r, "/")
return
}
@@ -344,42 +343,40 @@ func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
email := r.FormValue("email")
if email == "" {
utils.TriggerToast(w, r, "error", "Please enter an email")
utils.TriggerToast(w, r, "error", "Please enter an email", http.StatusBadRequest)
return
}
err := handler.service.SendForgotPasswordMail(email)
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (interface{}, error) {
err := handler.service.SendForgotPasswordMail(email)
return nil, err
})
if err != nil {
utils.TriggerToast(w, r, "error", "Internal Server Error")
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
} else {
utils.TriggerToast(w, r, "info", "If the email exists, an email has been sent")
utils.TriggerToast(w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
}
}
}
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL"))
if err != nil {
log.Error("Could not get current URL: %v", err)
utils.TriggerToast(w, r, "error", "Internal Server Error")
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
return
}
token := pageUrl.Query().Get("token")
if token == "" {
utils.TriggerToast(w, r, "error", "No token")
return
}
newPass := r.FormValue("new-password")
err = handler.service.ForgotPassword(token, newPass)
if err != nil {
utils.TriggerToast(w, r, "error", err.Error())
utils.TriggerToast(w, r, "error", err.Error(), http.StatusBadRequest)
} else {
utils.TriggerToast(w, r, "success", "Password changed")
utils.TriggerToast(w, r, "success", "Password changed", http.StatusOK)
}
}
}

View File

@@ -1,9 +1,9 @@
package handler
import (
"me-fit/handler/middleware"
"me-fit/service"
"me-fit/template"
"web-app-template/handler/middleware"
"web-app-template/service"
"web-app-template/template"
"net/http"
@@ -32,21 +32,19 @@ func (handler IndexImpl) Handle(router *http.ServeMux) {
func (handler IndexImpl) handleIndexAnd404() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := middleware.GetSession(r)
var user *service.User
if session != nil {
user = session.User
}
user := middleware.GetUser(r)
var comp templ.Component
var status int
if r.URL.Path != "/" {
comp = template.NotFound()
w.WriteHeader(http.StatusNotFound)
status = http.StatusNotFound
} else {
comp = template.Index()
status = http.StatusOK
}
handler.render.RenderLayout(r, w, comp, user)
handler.render.RenderLayoutWithStatus(r, w, comp, user, status)
}
}

View File

@@ -2,50 +2,63 @@ package middleware
import (
"context"
"me-fit/service"
"net/http"
"web-app-template/service"
"web-app-template/types"
)
type ContextKey string
var SessionKey ContextKey = "session"
var UserKey ContextKey = "user"
func Authenticate(service service.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionId := getSessionID(r)
session, _ := service.SignInSession(sessionId)
session, user, _ := service.SignInSession(sessionId)
if session != nil {
ctx := context.WithValue(r.Context(), SessionKey, session)
var err error
// Always sign in anonymous
// This way, we can always generate csrf tokens
if session == nil {
session, err = service.SignInAnonymous()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
next.ServeHTTP(w, r.WithContext(ctx))
} else {
next.ServeHTTP(w, r)
cookie := CreateSessionCookie(session.Id)
http.SetCookie(w, &cookie)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserKey, user)
ctx = context.WithValue(ctx, SessionKey, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetUser(r *http.Request) *service.User {
session := GetSession(r)
if session == nil {
func GetUser(r *http.Request) *types.User {
obj := r.Context().Value(UserKey)
if obj == nil {
return nil
}
return session.User
return obj.(*types.User)
}
func GetSession(r *http.Request) *service.Session {
func GetSession(r *http.Request) *types.Session {
obj := r.Context().Value(SessionKey)
if obj == nil {
return nil
}
return obj.(*service.Session)
return obj.(*types.Session)
}
func getSessionID(r *http.Request) string {

View File

@@ -0,0 +1,23 @@
package middleware
import (
"net/http"
"strings"
)
func CacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
cached := false
if strings.HasPrefix(path, "/static") {
cached = true
}
if !cached {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
next.ServeHTTP(w, r)
})
}

View File

@@ -1,29 +0,0 @@
package middleware
import "net/http"
func ContentSecurityPolicy(next http.Handler) http.Handler {
values := map[string]string{
"default-src": "'none'",
"script-src": "'self' https://umami.me-fit.eu",
"connect-src": "'self' https://umami.me-fit.eu",
"img-src": "'self'",
"style-src": "'self'",
"form-action": "'self'",
"frame-ancestors": "'none'",
}
var headerValue string
for key, value := range values {
headerValue += key + " " + value + "; "
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// While this value can be overridden, it can't be moved to after the next.ServeHTTP call,
// because if the response writer get's closed, the headers can't be set anymore
w.Header().Set("Content-Security-Policy", headerValue)
next.ServeHTTP(w, r)
})
}

View File

@@ -1,13 +0,0 @@
package middleware
import (
"net/http"
)
func Coop(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}

View File

@@ -1,13 +0,0 @@
package middleware
import (
"net/http"
)
func Corp(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}

View File

@@ -1,23 +0,0 @@
package middleware
import (
"me-fit/types"
"net/http"
)
func Cors(serverSettings *types.Settings) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", serverSettings.BaseUrl)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -2,20 +2,22 @@ package middleware
import (
"fmt"
"net/http"
"strings"
"me-fit/service"
"net/http"
"web-app-template/log"
"web-app-template/service"
"web-app-template/types"
"web-app-template/utils"
)
type csrfResponseWriter struct {
http.ResponseWriter
auth service.Auth
session *service.Session
session *types.Session
}
func newCsrfResponseWriter(w http.ResponseWriter, auth service.Auth, session *service.Session) *csrfResponseWriter {
func newCsrfResponseWriter(w http.ResponseWriter, auth service.Auth, session *types.Session) *csrfResponseWriter {
return &csrfResponseWriter{
ResponseWriter: w,
auth: auth,
@@ -25,12 +27,11 @@ func newCsrfResponseWriter(w http.ResponseWriter, auth service.Auth, session *se
func (rr *csrfResponseWriter) Write(data []byte) (int, error) {
dataStr := string(data)
if strings.Contains(dataStr, "</form>") {
csrfToken, err := rr.auth.GetCsrfToken(rr.session)
if err == nil {
csrfField := fmt.Sprintf(`<input type="hidden" name="csrf-token" value="%s">`, csrfToken)
dataStr = strings.ReplaceAll(dataStr, "</form>", csrfField+"</form>")
}
csrfToken, err := rr.auth.GetCsrfToken(rr.session)
if err == nil {
csrfInput := fmt.Sprintf(`<input type="hidden" name="csrf-token" value="%s" />`, csrfToken)
dataStr = strings.ReplaceAll(dataStr, "</form>", csrfInput+"</form>")
dataStr = strings.ReplaceAll(dataStr, "CSRF_TOKEN", csrfToken)
}
return rr.ResponseWriter.Write([]byte(dataStr))
@@ -52,31 +53,20 @@ func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler
r.Method == http.MethodPatch {
csrfToken := r.FormValue("csrf-token")
if csrfToken == "" || !auth.IsCsrfTokenValid(csrfToken, session.Id) {
http.Error(w, "", http.StatusForbidden)
if csrfToken == "" {
csrfToken = r.Header.Get("csrf-token")
}
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(csrfToken, session.Id) {
log.Info("CSRF-Token not correct")
if r.Header.Get("HX-Request") == "true" {
utils.TriggerToast(w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
} else {
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
}
return
}
}
if session == nil {
var err error
session, err = auth.SignInAnonymous()
if err != nil {
http.Error(w, "", http.StatusInternalServerError)
return
}
}
cookie := http.Cookie{
Name: "id",
Value: session.Id,
MaxAge: 60 * 60 * 8, // 8 hours
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
}
http.SetCookie(w, &cookie)
responseWriter := newCsrfResponseWriter(w, auth, session)
next.ServeHTTP(responseWriter, r)
})

View File

@@ -0,0 +1,15 @@
package middleware
import "net/http"
func CreateSessionCookie(sessionId string) http.Cookie {
return http.Cookie{
Name: "id",
Value: sessionId,
MaxAge: 60 * 60 * 8, // 8 hours
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
}
}

View File

@@ -1,12 +1,12 @@
package middleware
import (
"me-fit/log"
"net/http"
"strconv"
"time"
"web-app-template/log"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)

View File

@@ -0,0 +1,40 @@
package middleware
import (
"net/http"
"web-app-template/types"
)
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Access-Control-Allow-Origin", serverSettings.BaseUrl)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE")
w.Header().Set("Content-Security-Policy",
"default-src 'none'; "+
"script-src 'self'; "+
"connect-src 'self'; "+
"img-src 'self'; "+
"style-src 'self'; "+
"form-action 'self'; "+
"frame-ancestors 'none'; ",
)
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -1,11 +1,10 @@
package handler
import (
"me-fit/log"
"me-fit/service"
"me-fit/template"
"me-fit/template/auth"
"me-fit/types"
"web-app-template/log"
"web-app-template/template"
"web-app-template/template/auth"
"web-app-template/types"
"net/http"
@@ -13,16 +12,15 @@ import (
)
type Render struct {
settings *types.Settings
}
func NewRender(settings *types.Settings) *Render {
return &Render{
settings: settings,
}
func NewRender() *Render {
return &Render{}
}
func (render *Render) Render(r *http.Request, w http.ResponseWriter, comp templ.Component) {
func (render *Render) RenderWithStatus(r *http.Request, w http.ResponseWriter, comp templ.Component, status int) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(status)
err := comp.Render(r.Context(), w)
if err != nil {
log.Error("Failed to render layout: %v", err)
@@ -30,14 +28,22 @@ func (render *Render) Render(r *http.Request, w http.ResponseWriter, comp templ.
}
}
func (render *Render) RenderLayout(r *http.Request, w http.ResponseWriter, slot templ.Component, user *service.User) {
userComp := render.getUserComp(user)
layout := template.Layout(slot, userComp, render.settings.Environment)
render.Render(r, w, layout)
func (render *Render) Render(r *http.Request, w http.ResponseWriter, comp templ.Component) {
render.RenderWithStatus(r, w, comp, http.StatusOK)
}
func (render *Render) getUserComp(user *service.User) templ.Component {
func (render *Render) RenderLayout(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User) {
render.RenderLayoutWithStatus(r, w, slot, user, http.StatusOK)
}
func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User, status int) {
userComp := render.getUserComp(user)
layout := template.Layout(slot, userComp)
render.RenderWithStatus(r, w, layout, status)
}
func (render *Render) getUserComp(user *types.User) templ.Component {
if user != nil {
return auth.UserComp(user.Email)

View File

@@ -1,11 +1,10 @@
package handler
import (
"me-fit/handler/middleware"
"me-fit/log"
"me-fit/service"
"me-fit/template/workout"
"me-fit/utils"
"web-app-template/handler/middleware"
"web-app-template/service"
"web-app-template/template/workout"
"web-app-template/utils"
"net/http"
"strconv"
@@ -39,22 +38,22 @@ func (handler WorkoutImpl) Handle(router *http.ServeMux) {
func (handler WorkoutImpl) handleWorkoutPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := middleware.GetSession(r)
if session == nil {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
currentDate := time.Now().Format("2006-01-02")
comp := workout.WorkoutComp(currentDate)
handler.render.RenderLayout(r, w, comp, session.User)
handler.render.RenderLayout(r, w, comp, user)
}
}
func (handler WorkoutImpl) handleAddWorkout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := middleware.GetSession(r)
if session == nil {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
@@ -65,9 +64,9 @@ func (handler WorkoutImpl) handleAddWorkout() http.HandlerFunc {
var repsStr = r.FormValue("reps")
wo := service.NewWorkoutDto("", dateStr, typeStr, setsStr, repsStr)
wo, err := handler.service.AddWorkout(session.User, wo)
wo, err := handler.service.AddWorkout(user, wo)
if err != nil {
utils.TriggerToast(w, r, "error", "Invalid input values")
utils.TriggerToast(w, r, "error", "Invalid input values", http.StatusBadRequest)
http.Error(w, "Invalid input values", http.StatusBadRequest)
return
}
@@ -80,13 +79,13 @@ func (handler WorkoutImpl) handleAddWorkout() http.HandlerFunc {
func (handler WorkoutImpl) handleGetWorkout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := middleware.GetSession(r)
if session == nil {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
workouts, err := handler.service.GetWorkouts(session.User)
workouts, err := handler.service.GetWorkouts(user)
if err != nil {
return
}
@@ -103,33 +102,27 @@ func (handler WorkoutImpl) handleGetWorkout() http.HandlerFunc {
func (handler WorkoutImpl) handleDeleteWorkout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := middleware.GetSession(r)
if session == nil {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
rowId := r.PathValue("id")
if rowId == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
log.Warn("Missing required fields for workout delete")
utils.TriggerToast(w, r, "error", "Missing ID field")
utils.TriggerToast(w, r, "error", "Missing ID field", http.StatusBadRequest)
return
}
rowIdInt, err := strconv.Atoi(rowId)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
log.Warn("Invalid ID for workout delete")
utils.TriggerToast(w, r, "error", "Invalid ID")
utils.TriggerToast(w, r, "error", "Invalid ID", http.StatusBadRequest)
return
}
err = handler.service.DeleteWorkout(session.User, rowIdInt)
err = handler.service.DeleteWorkout(user, rowIdInt)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
log.Error("Could not delete workout: %v", err.Error())
utils.TriggerToast(w, r, "error", "Internal Server Error")
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
return
}
}

18
input.css Normal file
View File

@@ -0,0 +1,18 @@
@import 'tailwindcss';
@source './static/**/*.js';
@source './template/**/*.templ';
@theme {
--animate-fade: fadeOut 0.25s ease-in;
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
}

28
less
View File

@@ -1,28 +0,0 @@
__ _ ___
/ /\ | | | |_)
/_/--\ |_| |_| \_ v1.52.3, built with Go go1.22.5
mkdir /home/tiwun/source/me-fit/tmp
watching .
watching db
watching handler
watching handler/middleware
watching log
watching migration
watching mocks
!exclude node_modules
watching service
!exclude static
watching template
watching template/auth
watching template/mail
watching template/workout
!exclude tmp
watching types
watching utils
building...
(✓) Complete [ updates=12 duration=10.258748ms ]
cleaning...
deleting /home/tiwun/source/me-fit/tmp
see you again~

20
main.go
View File

@@ -1,12 +1,12 @@
package main
import (
"me-fit/db"
"me-fit/handler"
"me-fit/handler/middleware"
"me-fit/log"
"me-fit/service"
"me-fit/types"
"web-app-template/db"
"web-app-template/handler"
"web-app-template/handler/middleware"
"web-app-template/log"
"web-app-template/service"
"web-app-template/types"
"context"
"database/sql"
@@ -113,7 +113,7 @@ func createHandler(d *sql.DB, serverSettings *types.Settings) http.Handler {
authService := service.NewAuthImpl(authDb, randomService, clockService, mailService, serverSettings)
workoutService := service.NewWorkoutImpl(workoutDb, randomService, clockService, mailService, serverSettings)
render := handler.NewRender(serverSettings)
render := handler.NewRender()
indexHandler := handler.NewIndex(authService, render)
authHandler := handler.NewAuth(authService, render)
workoutHandler := handler.NewWorkout(workoutService, authService, render)
@@ -128,11 +128,9 @@ func createHandler(d *sql.DB, serverSettings *types.Settings) http.Handler {
return middleware.Wrapper(
router,
middleware.Log,
middleware.ContentSecurityPolicy,
middleware.Cors(serverSettings),
middleware.CacheControl,
middleware.SecurityHeaders(serverSettings),
middleware.Authenticate(authService),
middleware.CrossSiteRequestForgery(authService),
middleware.Corp,
middleware.Coop,
)
}

File diff suppressed because it is too large Load Diff

2081
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,18 @@
{
"name": "me-fit",
"name": "web-app-template",
"version": "1.0.0",
"description": "Your (almost) independent tech stack to host on a VPC.",
"main": "index.js",
"scripts": {
"build": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss build -o static/css/tailwind.css --minify",
"watch": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss build -o static/css/tailwind.css --watch",
"test": ""
"build": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --minify",
"watch": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"htmx.org": "2.0.3",
"tailwindcss": "3.4.16",
"daisyui": "4.12.14"
"htmx.org": "2.0.4",
"tailwindcss": "4.0.9",
"@tailwindcss/cli": "4.0.9"
}
}

View File

@@ -8,72 +8,43 @@ import (
"strings"
"time"
"me-fit/db"
"me-fit/log"
mailTemplate "me-fit/template/mail"
"me-fit/types"
"web-app-template/db"
"web-app-template/log"
mailTemplate "web-app-template/template/mail"
"web-app-template/types"
"github.com/google/uuid"
"golang.org/x/crypto/argon2"
)
var (
ErrInvaidCredentials = errors.New("invalid email or password")
ErrInvalidPassword = errors.New("password needs to be 8 characters long, contain at least one number, one special, one uppercase and one lowercase character")
ErrInvalidEmail = errors.New("invalid email")
ErrAccountExists = errors.New("account already exists")
ErrSessionIdInvalid = errors.New("session ID is invalid")
ErrInvalidCredentials = errors.New("invalid email or password")
ErrInvalidPassword = errors.New("password needs to be 8 characters long, contain at least one number, one special, one uppercase and one lowercase character")
ErrInvalidEmail = errors.New("invalid email")
ErrAccountExists = errors.New("account already exists")
ErrSessionIdInvalid = errors.New("session ID is invalid")
ErrTokenInvalid = errors.New("token is invalid")
)
type User struct {
Id uuid.UUID
Email string
EmailVerified bool
}
func NewUser(user *db.User) *User {
return &User{
Id: user.Id,
Email: user.Email,
EmailVerified: user.EmailVerified,
}
}
type Session struct {
Id string
CreatedAt time.Time
ExpiresAt time.Time
User *User
}
func NewSession(session *db.Session, user *User) *Session {
return &Session{
Id: session.Id,
CreatedAt: session.CreatedAt,
ExpiresAt: session.ExpiresAt,
User: user,
}
}
type Auth interface {
SignUp(email string, password string) (*User, error)
SignUp(email string, password string) (*types.User, error)
SendVerificationMail(userId uuid.UUID, email string)
VerifyUserEmail(token string) error
SignIn(email string, password string) (*Session, error)
SignInSession(sessionId string) (*Session, error)
SignInAnonymous() (*Session, error)
SignIn(session *types.Session, email string, password string) (*types.Session, *types.User, error)
SignInSession(sessionId string) (*types.Session, *types.User, error)
SignInAnonymous() (*types.Session, error)
SignOut(sessionId string) error
DeleteAccount(user *User) error
DeleteAccount(user *types.User, currPass string) error
ChangePassword(user *User, currPass, newPass string) error
ChangePassword(user *types.User, sessionId string, currPass, newPass string) error
SendForgotPasswordMail(email string) error
ForgotPassword(token string, newPass string) error
IsCsrfTokenValid(tokenStr string, sessionId string) bool
GetCsrfToken(session *Session) (string, error)
GetCsrfToken(session *types.Session) (string, error)
}
type AuthImpl struct {
@@ -94,76 +65,103 @@ func NewAuthImpl(db db.Auth, random Random, clock Clock, mail Mail, serverSettin
}
}
func (service AuthImpl) SignIn(email string, password string) (*Session, error) {
func (service AuthImpl) SignIn(session *types.Session, email string, password string) (*types.Session, *types.User, error) {
user, err := service.db.GetUserByEmail(email)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, ErrInvaidCredentials
return nil, nil, ErrInvalidCredentials
} else {
return nil, types.ErrInternal
return nil, nil, types.ErrInternal
}
}
hash := GetHashPassword(password, user.Salt)
if subtle.ConstantTimeCompare(hash, user.Password) == 0 {
return nil, ErrInvaidCredentials
return nil, nil, ErrInvalidCredentials
}
session, err := service.createSession(user.Id)
err = service.cleanUpSessionWithTokens(session)
if err != nil {
return nil, types.ErrInternal
return nil, nil, types.ErrInternal
}
return NewSession(session, NewUser(user)), nil
session, err = service.createSession(user.Id)
if err != nil {
return nil, nil, types.ErrInternal
}
return session, user, nil
}
func (service AuthImpl) SignInSession(sessionId string) (*Session, error) {
func (service AuthImpl) cleanUpSessionWithTokens(session *types.Session) error {
if session == nil {
return nil
}
err := service.db.DeleteSession(session.Id)
if err != nil {
return types.ErrInternal
}
tokens, err := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf)
if err != nil {
return types.ErrInternal
}
for _, token := range tokens {
err = service.db.DeleteToken(token.Token)
if err != nil {
return types.ErrInternal
}
}
return nil
}
func (service AuthImpl) SignInSession(sessionId string) (*types.Session, *types.User, error) {
if sessionId == "" {
return nil, ErrSessionIdInvalid
return nil, nil, ErrSessionIdInvalid
}
sessionDb, err := service.db.GetSession(sessionId)
session, err := service.db.GetSession(sessionId)
if err != nil {
return nil, nil, types.ErrInternal
}
if session.ExpiresAt.Before(service.clock.Now()) {
_ = service.db.DeleteSession(sessionId)
return nil, nil, nil
}
if session.UserId == uuid.Nil {
return session, nil, nil
}
user, err := service.db.GetUser(session.UserId)
if err != nil {
return nil, nil, types.ErrInternal
}
return session, user, nil
}
func (service AuthImpl) SignInAnonymous() (*types.Session, error) {
session, err := service.createSession(uuid.Nil)
if err != nil {
return nil, types.ErrInternal
}
if sessionDb.ExpiresAt.Before(service.clock.Now()) {
return nil, nil
}
if sessionDb.UserId == uuid.Nil {
return NewSession(sessionDb, nil), nil
}
userDb, err := service.db.GetUser(sessionDb.UserId)
if err != nil {
return nil, types.ErrInternal
}
user := NewUser(userDb)
session := NewSession(sessionDb, user)
log.Info("Anonymous session created: %v", session.Id)
return session, nil
}
func (service AuthImpl) SignInAnonymous() (*Session, error) {
sessionDb, err := service.createSession(uuid.Nil)
if err != nil {
return nil, types.ErrInternal
}
return NewSession(sessionDb, nil), nil
}
func (service AuthImpl) createSession(userId uuid.UUID) (*db.Session, error) {
func (service AuthImpl) createSession(userId uuid.UUID) (*types.Session, error) {
sessionId, err := service.random.String(32)
if err != nil {
return nil, types.ErrInternal
}
err = service.db.DeleteOldSessions(userId)
if err != nil {
return nil, types.ErrInternal
}
@@ -171,7 +169,7 @@ func (service AuthImpl) createSession(userId uuid.UUID) (*db.Session, error) {
createAt := service.clock.Now()
expiresAt := createAt.Add(24 * time.Hour)
session := db.NewSession(sessionId, userId, createAt, expiresAt)
session := types.NewSession(sessionId, userId, createAt, expiresAt)
err = service.db.InsertSession(session)
if err != nil {
@@ -181,7 +179,7 @@ func (service AuthImpl) createSession(userId uuid.UUID) (*db.Session, error) {
return session, nil
}
func (service AuthImpl) SignUp(email string, password string) (*User, error) {
func (service AuthImpl) SignUp(email string, password string) (*types.User, error) {
_, err := mail.ParseAddress(email)
if err != nil {
return nil, ErrInvalidEmail
@@ -203,9 +201,9 @@ func (service AuthImpl) SignUp(email string, password string) (*User, error) {
hash := GetHashPassword(password, salt)
dbUser := db.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
user := types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
err = service.db.InsertUser(dbUser)
err = service.db.InsertUser(user)
if err != nil {
if err == db.ErrAlreadyExists {
return nil, ErrAccountExists
@@ -214,17 +212,17 @@ func (service AuthImpl) SignUp(email string, password string) (*User, error) {
}
}
return NewUser(dbUser), nil
return user, nil
}
func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
tokens, err := service.db.GetTokensByUserIdAndType(userId, db.TokenTypeEmailVerify)
tokens, err := service.db.GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify)
if err != nil && err != db.ErrNotFound {
return
}
var token *db.Token
var token *types.Token
if len(tokens) > 0 {
token = tokens[0]
@@ -236,7 +234,7 @@ func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
return
}
token = db.NewToken(userId, "", newTokenStr, db.TokenTypeEmailVerify, service.clock.Now(), service.clock.Now().Add(24*time.Hour))
token = types.NewToken(userId, "", newTokenStr, types.TokenTypeEmailVerify, service.clock.Now(), service.clock.Now().Add(24*time.Hour))
err = service.db.InsertToken(token)
if err != nil {
@@ -251,7 +249,7 @@ func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
return
}
service.mail.SendMail(email, "Welcome to ME-FIT", w.String())
service.mail.SendMail(email, "Welcome to web-app-template", w.String())
}
func (service AuthImpl) VerifyUserEmail(tokenStr string) error {
@@ -270,7 +268,7 @@ func (service AuthImpl) VerifyUserEmail(tokenStr string) error {
return types.ErrInternal
}
if token.Type != db.TokenTypeEmailVerify {
if token.Type != types.TokenTypeEmailVerify {
return types.ErrInternal
}
@@ -297,9 +295,19 @@ func (service AuthImpl) SignOut(sessionId string) error {
return service.db.DeleteSession(sessionId)
}
func (service AuthImpl) DeleteAccount(user *User) error {
func (service AuthImpl) DeleteAccount(user *types.User, currPass string) error {
err := service.db.DeleteUser(user.Id)
userDb, err := service.db.GetUser(user.Id)
if err != nil {
return types.ErrInternal
}
currHash := GetHashPassword(currPass, userDb.Salt)
if subtle.ConstantTimeCompare(currHash, userDb.Password) == 0 {
return ErrInvalidCredentials
}
err = service.db.DeleteUser(user.Id)
if err != nil {
return err
}
@@ -309,7 +317,7 @@ func (service AuthImpl) DeleteAccount(user *User) error {
return nil
}
func (service AuthImpl) ChangePassword(user *User, currPass, newPass string) error {
func (service AuthImpl) ChangePassword(user *types.User, sessionId string, currPass, newPass string) error {
if !isPasswordValid(newPass) {
return ErrInvalidPassword
@@ -319,30 +327,37 @@ func (service AuthImpl) ChangePassword(user *User, currPass, newPass string) err
return ErrInvalidPassword
}
_, err := service.SignIn(user.Email, currPass)
currHash := GetHashPassword(currPass, user.Salt)
if subtle.ConstantTimeCompare(currHash, user.Password) == 0 {
return ErrInvalidCredentials
}
newHash := GetHashPassword(newPass, user.Salt)
user.Password = newHash
err := service.db.UpdateUser(user)
if err != nil {
return err
}
userDb, err := service.db.GetUser(user.Id)
sessions, err := service.db.GetSessions(user.Id)
if err != nil {
return err
return types.ErrInternal
}
newHash := GetHashPassword(newPass, userDb.Salt)
userDb.Password = newHash
err = service.db.UpdateUser(userDb)
if err != nil {
return err
for _, s := range sessions {
if s.Id != sessionId {
err = service.db.DeleteSession(s.Id)
if err != nil {
return types.ErrInternal
}
}
}
return nil
}
func (service AuthImpl) SendForgotPasswordMail(email string) error {
tokenStr, err := service.random.String(32)
if err != nil {
return err
@@ -357,7 +372,7 @@ func (service AuthImpl) SendForgotPasswordMail(email string) error {
}
}
token := db.NewToken(user.Id, "", tokenStr, db.TokenTypePasswordReset, service.clock.Now(), service.clock.Now().Add(15*time.Minute))
token := types.NewToken(user.Id, "", tokenStr, types.TokenTypePasswordReset, service.clock.Now(), service.clock.Now().Add(15*time.Minute))
err = service.db.InsertToken(token)
if err != nil {
@@ -383,7 +398,7 @@ func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
token, err := service.db.GetToken(tokenStr)
if err != nil {
return err
return ErrTokenInvalid
}
err = service.db.DeleteToken(tokenStr)
@@ -391,6 +406,11 @@ func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
return err
}
if token.Type != types.TokenTypePasswordReset ||
token.ExpiresAt.Before(service.clock.Now()) {
return ErrTokenInvalid
}
user, err := service.db.GetUser(token.UserId)
if err != nil {
log.Error("Could not get user from token: %v", err)
@@ -405,6 +425,18 @@ func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
return err
}
sessions, err := service.db.GetSessions(user.Id)
if err != nil {
return types.ErrInternal
}
for _, session := range sessions {
err = service.db.DeleteSession(session.Id)
if err != nil {
return types.ErrInternal
}
}
return nil
}
@@ -414,7 +446,7 @@ func (service AuthImpl) IsCsrfTokenValid(tokenStr string, sessionId string) bool
return false
}
if token.Type != db.TokenTypeCsrf ||
if token.Type != types.TokenTypeCsrf ||
token.SessionId != sessionId ||
token.ExpiresAt.Before(service.clock.Now()) {
@@ -424,12 +456,12 @@ func (service AuthImpl) IsCsrfTokenValid(tokenStr string, sessionId string) bool
return true
}
func (service AuthImpl) GetCsrfToken(session *Session) (string, error) {
func (service AuthImpl) GetCsrfToken(session *types.Session) (string, error) {
if session == nil {
return "", types.ErrInternal
}
tokens, _ := service.db.GetTokensBySessionIdAndType(session.Id, db.TokenTypeCsrf)
tokens, _ := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf)
if len(tokens) > 0 {
return tokens[0].Token, nil
@@ -440,12 +472,14 @@ func (service AuthImpl) GetCsrfToken(session *Session) (string, error) {
return "", types.ErrInternal
}
token := db.NewToken(uuid.Nil, session.Id, tokenStr, db.TokenTypeCsrf, service.clock.Now(), service.clock.Now().Add(24*time.Hour))
token := types.NewToken(session.UserId, session.Id, tokenStr, types.TokenTypeCsrf, service.clock.Now(), service.clock.Now().Add(8*time.Hour))
err = service.db.InsertToken(token)
if err != nil {
return "", types.ErrInternal
}
log.Info("CSRF-Token created: %v", tokenStr)
return tokenStr, nil
}

View File

@@ -1,11 +1,10 @@
package service
import (
"me-fit/db"
"me-fit/mocks"
"me-fit/types"
"web-app-template/db"
"web-app-template/mocks"
"web-app-template/types"
"errors"
"strings"
"testing"
"time"
@@ -15,104 +14,6 @@ import (
"github.com/stretchr/testify/mock"
)
func TestSignIn(t *testing.T) {
t.Parallel()
t.Run("should return user if password is correct", func(t *testing.T) {
t.Parallel()
salt := []byte("salt")
verifiedAt := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
user := db.NewUser(
uuid.New(),
"test@test.de",
true,
&verifiedAt,
false,
GetHashPassword("password", salt),
salt,
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
)
dbSession := db.NewSession("sessionId", user.Id, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC))
mockAuthDb := mocks.NewMockAuth(t)
mockAuthDb.EXPECT().GetUserByEmail("test@test.de").Return(user, nil)
mockAuthDb.EXPECT().DeleteOldSessions(user.Id).Return(nil)
mockAuthDb.EXPECT().InsertSession(dbSession).Return(nil)
mockRandom := mocks.NewMockRandom(t)
mockRandom.EXPECT().String(32).Return("sessionId", nil)
mockClock := mocks.NewMockClock(t)
mockClock.EXPECT().Now().Return(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))
mockMail := mocks.NewMockMail(t)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
actualSession, err := underTest.SignIn(user.Email, "password")
assert.Nil(t, err)
expectedSession := NewSession(dbSession, NewUser(user))
assert.Equal(t, expectedSession, actualSession)
})
t.Run("should return ErrInvalidCretentials if password is not correct", func(t *testing.T) {
t.Parallel()
salt := []byte("salt")
verifiedAt := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
user := db.NewUser(
uuid.New(),
"test@test.de",
true,
&verifiedAt,
false,
GetHashPassword("password", salt),
salt,
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
)
mockAuthDb := mocks.NewMockAuth(t)
mockAuthDb.EXPECT().GetUserByEmail(user.Email).Return(user, nil)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
_, err := underTest.SignIn("test@test.de", "wrong password")
assert.Equal(t, ErrInvaidCredentials, err)
})
t.Run("should return ErrInvalidCretentials if user has not been found", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuth(t)
mockAuthDb.EXPECT().GetUserByEmail("test").Return(nil, db.ErrNotFound)
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
_, err := underTest.SignIn("test", "test")
assert.Equal(t, ErrInvaidCredentials, err)
})
t.Run("should forward ErrInternal on any other error", func(t *testing.T) {
t.Parallel()
mockAuthDb := mocks.NewMockAuth(t)
mockAuthDb.EXPECT().GetUserByEmail("test").Return(nil, errors.New("Some undefined error"))
mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
_, err := underTest.SignIn("test", "test")
assert.Equal(t, types.ErrInternal, err)
})
}
func TestSignUp(t *testing.T) {
t.Parallel()
t.Run("should check for correct email address", func(t *testing.T) {
@@ -159,33 +60,25 @@ func TestSignUp(t *testing.T) {
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
expected := User{
Id: uuid.New(),
Email: "some@valid.email",
EmailVerified: false,
}
random := NewRandomImpl()
salt, err := random.Bytes(16)
assert.Nil(t, err)
userId := uuid.New()
email := "mail@mail.de"
password := "SomeStrongPassword123!"
mockRandom.EXPECT().UUID().Return(expected.Id, nil)
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
salt := []byte("salt")
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
mockClock.EXPECT().Now().Return(createTime)
expected := types.NewUser(userId, email, false, nil, false, GetHashPassword(password, salt), salt, createTime)
mockAuthDb.EXPECT().InsertUser(db.NewUser(expected.Id, expected.Email, false, nil, false, GetHashPassword(password, salt), salt, createTime)).Return(nil)
mockRandom.EXPECT().UUID().Return(userId, nil)
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(expected).Return(nil)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
actual, err := underTest.SignUp(expected.Email, password)
actual, err := underTest.SignUp(email, password)
assert.Nil(t, err)
assert.Equal(t, expected, *actual)
assert.Equal(t, expected, actual)
})
t.Run("should return ErrAccountExists", func(t *testing.T) {
t.Parallel()
@@ -195,28 +88,22 @@ func TestSignUp(t *testing.T) {
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
user := User{
Id: uuid.New(),
Email: "some@valid.email",
}
random := NewRandomImpl()
salt, err := random.Bytes(16)
assert.Nil(t, err)
userId := uuid.New()
email := "some@valid.email"
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
password := "SomeStrongPassword123!"
salt := []byte("salt")
user := types.NewUser(userId, email, false, nil, false, GetHashPassword(password, salt), salt, createTime)
mockRandom.EXPECT().UUID().Return(user.Id, nil)
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(db.NewUser(user.Id, user.Email, false, nil, false, GetHashPassword(password, salt), salt, createTime)).Return(db.ErrAlreadyExists)
mockAuthDb.EXPECT().InsertUser(user).Return(db.ErrAlreadyExists)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
_, err = underTest.SignUp(user.Email, password)
_, err := underTest.SignUp(user.Email, password)
assert.Equal(t, ErrAccountExists, err)
})
}
@@ -227,8 +114,8 @@ func TestSendVerificationMail(t *testing.T) {
t.Run("should use stored token and send mail", func(t *testing.T) {
t.Parallel()
token := db.NewToken(uuid.New(), "sessionId", "someRandomTokenToUse", db.TokenTypeEmailVerify, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC))
tokens := []*db.Token{token}
token := types.NewToken(uuid.New(), "sessionId", "someRandomTokenToUse", types.TokenTypeEmailVerify, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC))
tokens := []*types.Token{token}
email := "some@email.de"
userId := uuid.New()
@@ -238,9 +125,9 @@ func TestSendVerificationMail(t *testing.T) {
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
mockAuthDb.EXPECT().GetTokensByUserIdAndType(userId, db.TokenTypeEmailVerify).Return(tokens, nil)
mockAuthDb.EXPECT().GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify).Return(tokens, nil)
mockMail.EXPECT().SendMail(email, "Welcome to ME-FIT", mock.MatchedBy(func(message string) bool {
mockMail.EXPECT().SendMail(email, "Welcome to web-app-template", mock.MatchedBy(func(message string) bool {
return strings.Contains(message, token.Token)
})).Return()

View File

@@ -1,8 +1,8 @@
package service
import (
"me-fit/log"
"me-fit/types"
"web-app-template/log"
"web-app-template/types"
"fmt"
"net/smtp"

View File

@@ -1,8 +1,8 @@
package service
import (
"me-fit/log"
"me-fit/types"
"web-app-template/log"
"web-app-template/types"
"crypto/rand"
"encoding/base64"
@@ -37,6 +37,7 @@ func (r *RandomImpl) Bytes(size int) ([]byte, error) {
func (r *RandomImpl) String(size int) (string, error) {
bytes, err := r.Bytes(size)
if err != nil {
log.Error("Error generating random string: %v", err)
return "", types.ErrInternal
}

View File

@@ -1,8 +1,8 @@
package service
import (
"me-fit/db"
"me-fit/types"
"web-app-template/db"
"web-app-template/types"
"errors"
"strconv"
@@ -10,9 +10,9 @@ import (
)
type Workout interface {
AddWorkout(user *User, workoutDto *WorkoutDto) (*WorkoutDto, error)
DeleteWorkout(user *User, rowId int) error
GetWorkouts(user *User) ([]*WorkoutDto, error)
AddWorkout(user *types.User, workoutDto *WorkoutDto) (*WorkoutDto, error)
DeleteWorkout(user *types.User, rowId int) error
GetWorkouts(user *types.User) ([]*WorkoutDto, error)
}
type WorkoutImpl struct {
@@ -64,7 +64,7 @@ var (
ErrInputValues = errors.New("invalid input values")
)
func (service WorkoutImpl) AddWorkout(user *User, workoutDto *WorkoutDto) (*WorkoutDto, error) {
func (service WorkoutImpl) AddWorkout(user *types.User, workoutDto *WorkoutDto) (*WorkoutDto, error) {
if workoutDto.Date == "" || workoutDto.Type == "" || workoutDto.Sets == "" || workoutDto.Reps == "" {
return nil, ErrInputValues
@@ -95,7 +95,7 @@ func (service WorkoutImpl) AddWorkout(user *User, workoutDto *WorkoutDto) (*Work
return NewWorkoutDtoFromDb(workout), nil
}
func (service WorkoutImpl) DeleteWorkout(user *User, rowId int) error {
func (service WorkoutImpl) DeleteWorkout(user *types.User, rowId int) error {
if user == nil {
return types.ErrInternal
}
@@ -103,7 +103,7 @@ func (service WorkoutImpl) DeleteWorkout(user *User, rowId int) error {
return service.db.DeleteWorkout(user.Id, rowId)
}
func (service WorkoutImpl) GetWorkouts(user *User) ([]*WorkoutDto, error) {
func (service WorkoutImpl) GetWorkouts(user *types.User) ([]*WorkoutDto, error) {
if user == nil {
return nil, types.ErrInternal
}

View File

@@ -1,26 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./template/**/*.templ", "./static/**/*.js"],
theme: {
extend: {
animation: {
fade: 'fadeOut 0.25s ease-in',
},
keyframes: _ => ({
fadeOut: {
'0%': { opacity: '1' },
'100%': { opacity: '0' },
},
}),
},
},
plugins: [
require('daisyui'),
],
daisyui: {
themes: ["retro"],
},
}

View File

@@ -4,7 +4,7 @@ templ ChangePasswordComp(isPasswordReset bool) {
<form
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
if isPasswordReset {
hx-post="/api/auth/reset-password-actual"
hx-post="/api/auth/forgot-password-actual"
} else {
hx-post="/api/auth/change-password"
}
@@ -15,11 +15,29 @@ templ ChangePasswordComp(isPasswordReset bool) {
</h2>
if !isPasswordReset {
<label class="input input-bordered flex items-center gap-2">
<input type="password" class="grow" placeholder="Current Password" name="current-password"/>
<input
type="password"
class="grow"
placeholder="Current Password"
name="current-password"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
</label>
}
<label class="input input-bordered flex items-center gap-2">
<input type="password" class="grow" placeholder="New Password" name="new-password"/>
<input
type="password"
class="grow"
placeholder="New Password"
name="new-password"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
</label>
<button class="btn btn-primary self-end">
Change Password

View File

@@ -12,10 +12,19 @@ templ DeleteAccountComp() {
<p class="text-xl text-red-500 mb-4">
Are you sure you want to delete your account? This action is irreversible.
</p>
<label class="input input-bordered flex items-center gap-2">
<input type="password" class="grow" placeholder="Password" name="password"/>
<label class="flex items-center gap-2">
<input
type="password"
class="grow"
placeholder="Password"
name="password"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
</label>
<button class="btn btn-error self-end">
<button class="self-end">
Delete Account
</button>
</form>

View File

@@ -3,14 +3,23 @@ package auth
templ ResetPasswordComp() {
<form
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
hx-post="/api/auth/reset-password"
hx-post="/api/auth/forgot-password"
hx-swap="none"
>
<h2 class="text-6xl mb-10">
Reset Password
</h2>
<label class="input input-bordered flex items-center gap-2">
<input type="email" class="grow" placeholder="E-Mail" name="email"/>
<input
type="email"
class="grow"
placeholder="E-Mail"
name="email"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
</label>
<button class="btn btn-primary self-end">
Request Password Reset

View File

@@ -1,14 +1,18 @@
package auth
templ SignInOrUpComp(isSignIn bool) {
{{
var postUrl string
if isSignIn {
postUrl = "/api/auth/signin"
} else {
postUrl = "/api/auth/signup"
}
}}
<form
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"
hx-target="#sign-in-or-up-error"
if isSignIn {
hx-post="/api/auth/signin"
} else {
hx-post="/api/auth/signup"
}
hx-post={ postUrl }
>
<h2 class="text-6xl mb-10">
if isSignIn {
@@ -18,12 +22,7 @@ templ SignInOrUpComp(isSignIn bool) {
}
</h2>
<label class="input input-bordered flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4 opacity-70">
<path
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
></path>
@@ -31,26 +30,39 @@ templ SignInOrUpComp(isSignIn bool) {
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z"
></path>
</svg>
<input type="text" class="grow" placeholder="Email" name="email"/>
<input
type="text"
class="grow"
placeholder="Email"
name="email"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
</label>
<label class="input input-bordered flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4 opacity-70">
<path
fill-rule="evenodd"
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
clip-rule="evenodd"
></path>
</svg>
<input type="password" class="grow" placeholder="Password" name="password"/>
<input
type="password"
class="grow"
placeholder="Password"
name="password"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
</label>
<div class="flex justify-end items-center gap-2">
if isSignIn {
<a href="/auth/reset-password" class="grow link text-gray-500 text-sm">Forgot Password?</a>
<a href="/auth/forgot-password" class="grow link text-gray-500 text-sm">Forgot Password?</a>
<a href="/auth/signup" class="link text-gray-500 text-sm">Don't have an account? Sign Up</a>
<button class="btn btn-primary">
Sign In

View File

@@ -3,36 +3,28 @@ package auth
templ UserComp(user string) {
<div id="user-info" class="flex gap-5 items-center">
if user != "" {
<div class="group inline-block relative">
<button
class="font-semibold py-2 px-4 inline-flex items-center"
>
<div class="inline-block relative">
<button class="font-semibold py-2 px-4 inline-flex items-center">
<span class="mr-1">{ user }</span>
<svg
class="fill-current h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path
d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
></path>
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"></path>
</svg>
</button>
<div class="absolute hidden group-hover:block w-full">
<ul class="menu bg-base-300 rounded-box w-fit float-right mr-4 p-3">
<ul class="w-fit float-right mr-4 p-3">
<li class="mb-1">
<a hx-get="/api/auth/signout" hx-target="#user-info">Sign Out</a>
<a hx-post="/api/auth/signout" hx-target="#user-info">Sign Out</a>
</li>
<li class="mb-1">
<a href="/auth/change-password">Change Password</a>
</li>
<li><a href="/auth/delete-account" class="text-error">Delete Account</a></li>
<li><a href="/auth/delete-account" class="">Delete Account</a></li>
</ul>
</div>
</div>
} else {
<a href="/auth/signup" class="btn btn-sm">Sign Up</a>
<a href="/auth/signin" class="btn btn-sm">Sign In</a>
<a href="/auth/signup" class="">Sign Up</a>
<a href="/auth/signin" class="">Sign In</a>
}
</div>
}

View File

@@ -12,7 +12,7 @@ templ VerifyComp() {
<p class="text-lg text-center">
Please check your inbox/spam and click on the link to verify your account.
</p>
<button class="btn mt-8" hx-get="/api/auth/verify-resend" hx-sync="this:drop" hx-swap="outerHTML">
<button class="mt-8" hx-get="/api/auth/verify-resend" hx-sync="this:drop" hx-swap="outerHTML">
resend verification email
</button>
</div>

View File

@@ -0,0 +1,29 @@
package auth
templ VerifyResponseComp(isVerified bool) {
<main>
<div class="flex flex-col items-center justify-center h-screen">
if isVerified {
<h2 class="text-6xl mb-10">
Your email has been verified
</h2>
<p class="text-lg text-center">
You have completed the verification process. Thank you!
</p>
<a class="mt-8" href="/">
Go Home
</a>
} else {
<h2 class="text-6xl mb-10">
Error during verification
</h2>
<p class="text-lg text-center">
Please try again by sign up process
</p>
<a class="mt-8" href="/auth/signup">
Sign Up
</a>
}
</div>
</main>
}

View File

@@ -1,15 +1,15 @@
package template
templ Index() {
<div class="hero bg-base-200 h-full">
<div class="hero-content text-center">
<div class="h-full">
<div class="text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Next Level Workout Tracker</h1>
<p class="py-6">
Ever wanted to track your workouts and see your progress over time? ME-FIT is the perfect
Ever wanted to track your workouts and see your progress over time? web-app-template is the perfect
solution for you.
</p>
<a href="/workout" class="btn btn-primary">Get Started</a>
<a href="/workout" class="">Get Started</a>
</div>
</div>
</div>

View File

@@ -1,17 +1,14 @@
package template
templ Layout(slot templ.Component, user templ.Component, environment string) {
templ Layout(slot templ.Component, user templ.Component) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>ME-FIT</title>
<title>web-app-template</title>
<link rel="icon" href="/static/favicon.svg"/>
<link rel="stylesheet" href="/static/css/tailwind.css"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
if environment == "prod" {
<script defer src="https://umami.me-fit.eu/script.js" data-website-id="3c8efb09-44e4-4372-8a1e-c3bc675cd89a"></script>
}
<meta
name="htmx-config"
content='{
@@ -23,12 +20,12 @@ templ Layout(slot templ.Component, user templ.Component, environment string) {
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/toast.js"></script>
</head>
<body>
<body hx-headers='{"csrf-token": "CSRF_TOKEN"}'>
<div class="h-screen flex flex-col">
<div class="flex justify-end items-center gap-2 py-1 px-2 h-12 md:gap-10 md:px-10 md:py-2 shadow">
<div class="flex justify-end items-center gap-2 py-1 px-2 h-12 md:gap-10 md:px-10 md:py-2 shadow-sm">
<a href="/" class="flex-1 flex gap-2">
<img src="/static/favicon.svg" alt="ME-FIT logo"/>
<span>ME-FIT</span>
<img src="/static/favicon.svg" alt="web-app-template logo"/>
<span>web-app-template</span>
</a>
@user
</div>
@@ -38,8 +35,8 @@ templ Layout(slot templ.Component, user templ.Component, environment string) {
}
</div>
</div>
<div class="toast" id="toasts">
<div class="hidden alert" id="toast">
<div class="" id="toasts">
<div class="hidden" id="toast">
New message arrived.
</div>
</div>

View File

@@ -3,17 +3,21 @@ package mail;
import "net/url"
templ Register(baseUrl string, token string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Welcome</title>
</head>
<body>
<h4>Thank you for Sign Up!</h4>
<p>Click <a href={ templ.URL(baseUrl + "/auth/verify-email?token=" + url.QueryEscape(token)) }>here</a> to verify your account.</p>
<p>Kind regards</p>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome</title>
</head>
<body>
<h4>Thank you for Sign Up!</h4>
<p>Click <a href={ templ.URL(baseUrl + "/auth/verify-email?token=" + url.QueryEscape(token)) }>here</a> to finalize
your registration.</p>
<p>Kind regards</p>
</body>
</html>
}

View File

@@ -1,11 +1,11 @@
package template
templ NotFound() {
<main class="flex h-full justify-center items-center ">
<div class="bg-error p-16 rounded-lg">
<h1 class="text-4xl text-error-content mb-5">Not Found</h1>
<p class="text-lg text-error-content mb-5">The page you are looking for does not exist.</p>
<a href="/" class="btn btn-lg btn-primary">Go back to home</a>
<main class="flex h-full justify-center items-center">
<div class="p-16 rounded-lg">
<h1 class="text-4xl mb-5">Not Found</h1>
<p class="text-lg mb-5">The page you are looking for does not exist.</p>
<a href="/" class="">Go back to home</a>
</div>
</main>
}

View File

@@ -9,14 +9,14 @@ templ WorkoutComp(currentDate string) {
hx-swap="outerHTML"
>
<h2 class="text-4xl mb-8">Track your workout</h2>
<input id="date" type="date" class="input input-bordered" value={ currentDate } name="date"/>
<select class="select select-bordered w-full" name="type">
<input id="date" type="date" class="" value={ currentDate } name="date"/>
<select class="w-full" name="type">
<option>Push Ups</option>
<option>Pull Ups</option>
</select>
<input type="number" class="input input-bordered" placeholder="Sets" name="sets"/>
<input type="number" class="input input-bordered" placeholder="Reps" name="reps"/>
<button class="btn btn-primary self-end">Save</button>
<input type="number" class="" placeholder="Sets" name="sets"/>
<input type="number" class="" placeholder="Reps" name="reps"/>
<button class="self-end">Save</button>
</form>
<div hx-get="/api/workout" hx-trigger="load"></div>
</main>
@@ -31,7 +31,7 @@ type Workout struct {
}
templ WorkoutListComp(workouts []Workout) {
<div class="overflow-x-auto mx-auto max-w-screen-lg">
<div class="overflow-x-auto mx-auto max-w-lg">
<h2 class="text-4xl mt-14 mb-8">Workout history</h2>
<table class="table table-auto max-w-full">
<thead>
@@ -64,7 +64,7 @@ templ WorkoutItemComp(w Workout, includePlaceholder bool) {
<th>{ w.Reps }</th>
<th>
<div class="tooltip" data-tip="Delete Entry">
<button hx-delete={ "api/workout/" + w.Id } hx-target="closest tr">
<button hx-delete={ "api/workout/" + w.Id } hx-target="closest tr" type="submit">
Delete
</button>
</div>

75
types/auth.go Normal file
View File

@@ -0,0 +1,75 @@
package types
import (
"time"
"github.com/google/uuid"
)
type User struct {
Id uuid.UUID
Email string
EmailVerified bool
EmailVerifiedAt *time.Time
IsAdmin bool
Password []byte
Salt []byte
CreateAt time.Time
}
func NewUser(id uuid.UUID, email string, emailVerified bool, emailVerifiedAt *time.Time, isAdmin bool, password []byte, salt []byte, createAt time.Time) *User {
return &User{
Id: id,
Email: email,
EmailVerified: emailVerified,
EmailVerifiedAt: emailVerifiedAt,
IsAdmin: isAdmin,
Password: password,
Salt: salt,
CreateAt: createAt,
}
}
type Session struct {
Id string
UserId uuid.UUID
CreatedAt time.Time
ExpiresAt time.Time
}
func NewSession(id string, userId uuid.UUID, createdAt time.Time, expiresAt time.Time) *Session {
return &Session{
Id: id,
UserId: userId,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
}
}
type Token struct {
UserId uuid.UUID
SessionId string
Token string
Type TokenType
CreatedAt time.Time
ExpiresAt time.Time
}
type TokenType string
var (
TokenTypeEmailVerify TokenType = "email_verify"
TokenTypePasswordReset TokenType = "password_reset"
TokenTypeCsrf TokenType = "csrf"
)
func NewToken(userId uuid.UUID, sessionId string, token string, tokenType TokenType, createdAt time.Time, expiresAt time.Time) *Token {
return &Token{
UserId: userId,
SessionId: sessionId,
Token: token,
Type: tokenType,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
}
}

View File

@@ -1,7 +1,7 @@
package types
import (
"me-fit/log"
"web-app-template/log"
)
type Settings struct {

View File

@@ -1,16 +1,17 @@
package utils
import (
"me-fit/log"
"fmt"
"net/http"
"time"
"web-app-template/log"
)
func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string) {
func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) {
if isHtmx(r) {
w.Header().Set("HX-Trigger", fmt.Sprintf(`{"toast": "%v|%v"}`, class, message))
w.WriteHeader(statusCode)
} else {
log.Error("Trying to trigger toast in non-HTMX request")
}