Compare commits
446 Commits
3c01d4ce23
...
main
Author | SHA1 | Date | |
---|---|---|---|
e86f73ec02 | |||
bdc42beb27 | |||
72083fa0a4 | |||
b3ad13e55d | |||
0db54e0268 | |||
b3ef96a0ce | |||
a773f1f8b4 | |||
1a71f50914 | |||
d17e154e42 | |||
bad26775eb | |||
c3fd6637a5 | |||
20173ea140 | |||
41d439beab | |||
b36b6e18ca | |||
1765485027 | |||
c5fb5e674a | |||
03bf655051 | |||
f8589fce5d | |||
ac62158de9 | |||
44e1a18e9a | |||
e0f2c3219f | |||
d2d52dc041 | |||
d7d142152c | |||
5c321311cd | |||
ffc8a5f44d | |||
dbff94e7b3 | |||
0b1ef77689 | |||
b0005c6702 | |||
c2abf1a5ba | |||
ac50929e6e | |||
32785398ca | |||
cc497b6016 | |||
97ffcbdaa4 | |||
d09bf8ff02 | |||
c0814093e5 | |||
d48519741d | |||
213991126d | |||
c7e88606e3 | |||
a0d506fb76 | |||
0ec7743fca | |||
a8a0fa55b7 | |||
7321448ce7 | |||
6d5e0fc9a9 | |||
ef05d66787 | |||
b3296c45ad | |||
7fd163f957 | |||
0f0d50a684 | |||
68074e02bc | |||
8d38a86f86 | |||
e0c095c24d | |||
4c318b15cd | |||
5ea6bc4251 | |||
1cbe908489 | |||
53ee156e67 | |||
07a936acc7 | |||
f892f0da24 | |||
38955ee4e6 | |||
7fa7e87e88 | |||
f085f2e271 | |||
08add259a4 | |||
5b6142dfa6 | |||
dc41521a99 | |||
299e7eddc4 | |||
b4699e987c | |||
be9d4f2a1b | |||
db99236501 | |||
e33fb04c99 | |||
c3d4208e12 | |||
3f945fa329 | |||
93a5cd7c70 | |||
951de989af | |||
c5cf253a15 | |||
d70032e36d | |||
445ac50537 | |||
c13e564b01 | |||
5d3a77133d | |||
44592ebc08 | |||
1b941cb0c3 | |||
1cb520c2b6 | |||
b3fdd3bc18 | |||
9110db2f08 | |||
5972735102 | |||
14c69349cc | |||
3ceeab04e1 | |||
b7a67c208f | |||
cee885a84d | |||
4b496ea9bd | |||
570ae6ac8c | |||
f0d3a61e7b | |||
b09567620f | |||
2209846525 | |||
108ed61961 | |||
b73bfd590b | |||
19d56159ba | |||
b9ccf6adac | |||
9a0870dbbc | |||
70429f69a2 | |||
ad635008eb | |||
eacc8fdd89 | |||
d309fb3f57 | |||
f4fd993679 | |||
016879b53f | |||
70bba15cda | |||
57daf175ab | |||
0817a65272 | |||
13f9da1a67 | |||
83535acf1c | |||
441ce2daca | |||
f9848d2110 | |||
7f0511b0d4 | |||
a27f2ad593 | |||
715a984241 | |||
66e1756ade | |||
849403a137 | |||
8d15c9b8b2 | |||
87af1834cf | |||
357583f54d | |||
aa6de76ded | |||
ab3c2d1eb0 | |||
644cf2a358 | |||
5b1ed9925d | |||
4071a50a37 | |||
dd7c51efd8 | |||
8902f4d187 | |||
6666b20464 | |||
c5f288ba1e | |||
cc49ab1655 | |||
06c60b3491 | |||
b584a7b07f | |||
410e420a46 | |||
eeb0f6eac1 | |||
fb622f918a | |||
a50bad417f | |||
c395729446 | |||
eaa92d2fe4 | |||
a9e382d713 | |||
974244025e | |||
ae41076673 | |||
cc7f7f40c4 | |||
e2ae03f2a6 | |||
9319564dea | |||
83e3e5a2e9 | |||
2b40e4e922 | |||
ed33d03fda | |||
34c152a459 | |||
ad09e98bba | |||
d3fd5cba16 | |||
64dbb4368c | |||
cb3a6ddc58 | |||
e774f415d8 | |||
d5a22895e7 | |||
9983c51e3a | |||
6a1fc193f4 | |||
118877f727 | |||
7b8fe6baf2 | |||
e85b23b3e8 | |||
6164b77bee | |||
8b5a5744ab | |||
d9e9c5ab38 | |||
0dcef81b59 | |||
426b70a1de | |||
912973cdb5 | |||
e4ff799f05 | |||
320715f5aa | |||
a3b04b6243 | |||
f610d7480f | |||
11ac92a026 | |||
98ae3e06e9 | |||
a1146ce371 | |||
a67ec7e78c | |||
9895392b50 | |||
c6998f33e1 | |||
81659181e4 | |||
849b5935c2 | |||
c27d837ab0 | |||
92e9b87227 | |||
243b7cce33 | |||
76d960619f | |||
3e59c78287 | |||
6cd9da69ab | |||
29b97a87b3 | |||
8bc4603274 | |||
cc60a1ba86 | |||
89c7dc43e5 | |||
95c330568d | |||
900d314a95 | |||
0d8a3b1b39 | |||
4b7396c210 | |||
d4e2cbdd4f | |||
5024ac8151 | |||
3bf08c5933 | |||
b42da50306 | |||
0efc90567b | |||
a5466f1b10 | |||
8e946cbee5 | |||
a3a6b5e4d7 | |||
ad0a0f5626 | |||
2389058ddc | |||
ce44ef3e62 | |||
9ee30d1e23 | |||
886d0a7f5c | |||
cfdf419460 | |||
930e069aee | |||
1ef261660f | |||
5d9e5d27bf | |||
3f8a4024ce | |||
e8a74999c3 | |||
dc2ce1f349 | |||
bce775f692 | |||
45bce711f2 | |||
8ab2ddbe8e | |||
7f9b719b2b | |||
2b7f4995ef | |||
321f4087e1 | |||
9d19b470bc | |||
68493be36e | |||
f8772f8de2 | |||
d451331c66 | |||
485cfc2d12 | |||
944c650ab3 | |||
dfc5587608 | |||
96bdbfda95 | |||
05a234b7a5 | |||
2f58c01c24 | |||
e92dde20ca | |||
63437d6dc7 | |||
cef9dae4d3 | |||
0d7b1355d5 | |||
a213ea85d0 | |||
5c43f6d72a | |||
e49c0bbe45 | |||
413a11ee63 | |||
4a112318bd | |||
9897eb1f5d | |||
3fc7ceac23 | |||
aa48c21466 | |||
0ca2bb3f89 | |||
cd5adcdc3f | |||
62c90d0597 | |||
665d12a828 | |||
4d455fd62e | |||
348aacfde2 | |||
de17870bdb | |||
54581742dc | |||
639575dae0 | |||
800e1afbe5 | |||
8abc4396ac | |||
70f860824c | |||
3c5e31cbb2 | |||
3923b428a4 | |||
5b816c6873 | |||
66edadfeda | |||
b872722e07 | |||
091218b42d | |||
03697b2f67 | |||
8a28fca3d9 | |||
1ab4113040 | |||
013f300513 | |||
45e31b41ca | |||
182f30f1ba | |||
7c97ebd84f | |||
9fefe3ac71 | |||
ca3006c428 | |||
51b7e6b3f9 | |||
db2cb36f54 | |||
78e84567c7 | |||
0423b3803f | |||
60e317b9e4 | |||
aa18b9f3e2 | |||
d9ca1ce2b4 | |||
41c3dfdfe4 | |||
725cc74102 | |||
83c26bb94a
|
|||
6be3aa07a1
|
|||
54021c3021
|
|||
4b3a814d7e | |||
dd5c59afa8 | |||
0723a48ab0 | |||
ffefee930a | |||
a7ddd3d1ff | |||
21cedeabbd | |||
807d7538a0 | |||
8364a8e9ec | |||
56755ac531 | |||
e4d83e75a0 | |||
3dd91cf238 | |||
03d6730151 | |||
aa152a4127 | |||
a1ed1113d9 | |||
2187c873ee | |||
595015f324 | |||
8504f9c230 | |||
04db9b8ef2 | |||
e983719601 | |||
c5c55f72b1 | |||
c445756296 | |||
0166e62e98 | |||
8e22a3ac05 | |||
05ee30f6db | |||
dd8c453c54 | |||
52870cb541 | |||
14b37c2220 | |||
5604a824fe | |||
7d0ddd4d77 | |||
07b9b94143 | |||
b95dcc6230 | |||
e8bad71f21 | |||
4df7561dd3 | |||
8f753b2561 | |||
11748bb68e | |||
491c9a824d | |||
476b9a13d9 | |||
42665fffbb | |||
a157a3ec0e | |||
024d07fdd6 | |||
23845e25dd | |||
47209c311c | |||
2caef38ce6 | |||
e88980e64f | |||
159e4ad0e2 | |||
6e2d67ad24 | |||
d46e296ce1 | |||
e98806e96f | |||
0ab82e2503 | |||
34c1ce7652 | |||
428dc50aa1 | |||
2663264f50 | |||
ffba961d72 | |||
5a939c0771 | |||
87916f96fd | |||
c6c03e9cb6 | |||
6fd7171450 | |||
ae07d2d3d9 | |||
65545a0d71 | |||
d423d9ba62 | |||
d64c8479f8 | |||
3279f1fb90 | |||
1819629008 | |||
4e9fa2337b | |||
68899e98bd | |||
47f5188961 | |||
1840194bae | |||
d3bcc785a1 | |||
64faa4ca5f | |||
24c72800ad | |||
f559f54683 | |||
9b0de4512b | |||
b6d365cc48 | |||
8f755b6d1e | |||
587a463623 | |||
1a596eef87 | |||
e6b87a6561 | |||
3bcc5f8900 | |||
c5ee912408 | |||
9766da7cfd | |||
a004a82272 | |||
64ca9b922e | |||
38a2ce1ce9 | |||
accde2662f | |||
92fda8cb24 | |||
eee3839dea | |||
b941561ccf | |||
0bda5495c4 | |||
b5f5346536 | |||
68e2ece877 | |||
04bd27607f | |||
eb42b61b2c | |||
06e0e90677 | |||
eaf3596580 | |||
b8f3fa0a32 | |||
d4adc1b538 | |||
edfa3e63b9 | |||
7c58473ff1 | |||
9473c83679 | |||
0b8c03e8c5 | |||
55eb4c9862 | |||
de28470432 | |||
8ccf9f281c | |||
a9df6fa559 | |||
eb9c2b1da1 | |||
af8b347173 | |||
ba89880f8a | |||
07e1cbc66f | |||
aee3306c2b | |||
d2cb426170 | |||
e9e1414c90 | |||
4a71f6c5ee | |||
a8e75d75f0 | |||
afc9208269 | |||
ac07b5d723 | |||
9267cf2618 | |||
55ccd8ea8e | |||
fdf99d82e5 | |||
d9c3223228 | |||
3f369de3fa | |||
3cfc89eb39 | |||
6ea585cf23 | |||
eba6253ff6
|
|||
ef7e1a80d9
|
|||
854e1b44a9
|
|||
607174110c
|
|||
c283998403
|
|||
986ca8e353
|
|||
20d9947642
|
|||
97e15e1b1e
|
|||
d50bd6c4f5
|
|||
cb91a10192
|
|||
1a2130992b
|
|||
9fad610a70
|
|||
1e7ac51ca0
|
|||
8e181ccc07
|
|||
f63b0e9731
|
|||
ca0d7930b1
|
|||
6927ebf0d3
|
|||
00808c1c61
|
|||
0198d4c348
|
|||
c1b6143503
|
|||
94a873f10a
|
|||
703fd3174b
|
|||
584ee8865d
|
|||
2c1ba0a8fb
|
|||
555417110b
|
|||
7fda8896ad
|
|||
7fd104b6d0
|
|||
abddaaf51a
|
|||
7d6a521f12
|
|||
24a9c8a9ac
|
|||
e2a55588df
|
|||
e7354df10e
|
|||
66b7df32f3
|
|||
9a48522aa7
|
|||
097eef0016
|
|||
33c4dbaca9
|
|||
7b342a5fec
|
|||
49432dcbe5
|
|||
afd5b588d6
|
|||
c8d342d050
|
|||
fa30f66b6b
|
|||
f382ccc008
|
|||
512d76a380
|
|||
bc9b2d44b2
|
|||
b7dbe1ef0d
|
|||
299e586d69
|
|||
2e15406237
|
|||
0958e96310
|
|||
338ecf660c
|
63
.air.toml
Normal file
63
.air.toml
Normal file
@ -0,0 +1,63 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "bin\\hspguard"
|
||||
cmd = "make build"
|
||||
delay = 1000
|
||||
exclude_dir = [
|
||||
"assets",
|
||||
"tmp",
|
||||
"vendor",
|
||||
"testdata",
|
||||
"dist",
|
||||
"migrations",
|
||||
"queries",
|
||||
"scripts",
|
||||
"templates",
|
||||
"web",
|
||||
]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
24
.env.example
Normal file
24
.env.example
Normal file
@ -0,0 +1,24 @@
|
||||
|
||||
GUARD_PORT=3001
|
||||
GUARD_HOST="127.0.0.1"
|
||||
GUARD_URI="http://localhost:3001"
|
||||
|
||||
GUARD_DB_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
|
||||
|
||||
GUARD_REDIS_URL="redis://guard:guard@localhost:6379/0"
|
||||
|
||||
GUARD_ADMIN_NAME="admin"
|
||||
GUARD_ADMIN_EMAIL="admin@test.net"
|
||||
GUARD_ADMIN_PASSWORD="secret"
|
||||
|
||||
GUARD_JWT_PRIVATE="rsa"
|
||||
GUARD_JWT_PUBLIC="rsa"
|
||||
GUARD_JWT_KID="my-rsa-key-1"
|
||||
|
||||
GUARD_MINIO_ENDPOINT="localhost:9000"
|
||||
GUARD_MINIO_ACCESS_KEY=""
|
||||
GUARD_MINIO_SECRET_KEY=""
|
||||
|
||||
GOOSE_DRIVER="postgres"
|
||||
GOOSE_DBSTRING=$DATABASE_URL
|
||||
GOOSE_MIGRATION_DIR="./migrations"
|
10
.gitignore
vendored
10
.gitignore
vendored
@ -13,6 +13,8 @@
|
||||
|
||||
bin/*
|
||||
|
||||
tmp/
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
@ -25,3 +27,11 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
.env.remote
|
||||
|
||||
# key files
|
||||
*.pem
|
||||
|
||||
NUL
|
||||
|
||||
dist/
|
||||
|
55
Dockerfile
Normal file
55
Dockerfile
Normal file
@ -0,0 +1,55 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:22 AS frontend-builder
|
||||
WORKDIR /app/web
|
||||
COPY web/ .
|
||||
RUN npm install && npm run build
|
||||
|
||||
# Stage 2: Build backend
|
||||
FROM golang:1.24 AS backend-builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
# Copy built frontend into Go embed path (adjust if needed)
|
||||
# COPY --from=frontend-builder /app/web/dist ./web/dist
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux make build
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM debian:bookworm-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install CA certificates for HTTPS
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=backend-builder /app/bin/hspguard .
|
||||
COPY --from=frontend-builder /app/dist ./dist
|
||||
|
||||
COPY redis.conf /config/redis.conf
|
||||
|
||||
# Optional: copy default .env file if used
|
||||
# COPY .env .env
|
||||
|
||||
# Set environment variables (can be overridden at runtime)
|
||||
ENV ENV=production \
|
||||
GUARD_PORT=3001 \
|
||||
GUARD_HOST="127.0.0.1" \
|
||||
GUARD_URI="http://localhost:3001" \
|
||||
GUARD_DB_URL="postgres://user:user@localhost:5432/db?sslmode=disable" \
|
||||
GUARD_ADMIN_NAME="admin" \
|
||||
GUARD_ADMIN_EMAIL="admin@test.net" \
|
||||
GUARD_ADMIN_PASSWORD="secret" \
|
||||
GUARD_JWT_PRIVATE="rsa" \
|
||||
GUARD_JWT_PUBLIC="rsa" \
|
||||
GUARD_JWT_KID="my-rsa-key-1" \
|
||||
GUARD_MINIO_ENDPOINT="localhost:9000" \
|
||||
GUARD_MINIO_ACCESS_KEY="" \
|
||||
GUARD_MINIO_SECRET_KEY="" \
|
||||
GOOSE_DRIVER="postgres" \
|
||||
GOOSE_DBSTRING=$GUARD_DB_URL \
|
||||
GOOSE_MIGRATION_DIR="./migrations"
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["./hspguard"]
|
373
LICENSE
Normal file
373
LICENSE
Normal file
@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
19
Makefile
19
Makefile
@ -1,11 +1,14 @@
|
||||
|
||||
# Project metadata
|
||||
APP_NAME := hspguard
|
||||
CMD_DIR := ./cmd/$(APP_NAME)
|
||||
BIN_DIR := ./bin
|
||||
BIN_PATH := $(BIN_DIR)/$(APP_NAME)
|
||||
|
||||
# Detect platform and add .exe suffix on Windows
|
||||
OS := $(shell go env GOOS)
|
||||
EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||
BIN_PATH := $(BIN_DIR)/$(APP_NAME)$(EXT)
|
||||
|
||||
PKG := ./...
|
||||
GO_FILES := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
|
||||
|
||||
# Go tools
|
||||
GO := go
|
||||
@ -16,21 +19,20 @@ GOTEST := go test
|
||||
# Build flags
|
||||
LD_FLAGS := -s -w
|
||||
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>NUL || echo unknown)
|
||||
|
||||
.PHONY: all build clean fmt lint run
|
||||
.PHONY: all build clean fmt lint run test mod
|
||||
|
||||
all: build
|
||||
|
||||
build:
|
||||
@mkdir -p $(BIN_DIR)
|
||||
$(GO) build -ldflags "-X main.buildTime=$(BUILD_TIME) -X main.commitHash=$(GIT_COMMIT) $(LD_FLAGS)" -o $(BIN_PATH) $(CMD_DIR)
|
||||
|
||||
run:
|
||||
$(GO) run $(CMD_DIR)
|
||||
|
||||
fmt:
|
||||
$(GOFMT) -s -w $(GO_FILES)
|
||||
$(GOFMT) -s -w .
|
||||
|
||||
lint:
|
||||
$(GOLINT) run
|
||||
@ -39,8 +41,7 @@ test:
|
||||
$(GOTEST) -v $(PKG)
|
||||
|
||||
clean:
|
||||
@rm -rf $(BIN_DIR)
|
||||
@if [ -d "$(BIN_DIR)" ]; then rm -rf $(BIN_DIR); fi
|
||||
|
||||
mod:
|
||||
$(GO) mod tidy
|
||||
|
||||
|
168
README.md
168
README.md
@ -1,123 +1,139 @@
|
||||
# HSP Guard
|
||||
|
||||
**HSP Guard** is an internal security service for your home lab, designed to manage user access to various home services and tools. It dynamically controls permissions and prevents unauthorized or unexpected users from accessing sensitive services.
|
||||
# 🛡️ HSP Guard
|
||||
|
||||
**HSP Guard** is a modern OpenID Connect (OIDC) identity provider and access management system for home labs. It provides secure authentication and granular authorization for all your self-hosted services, combining ease of use with enterprise-level control — without any vendor lock-in.
|
||||
|
||||
---
|
||||
|
||||
## 📌 Overview
|
||||
## ✨ Features
|
||||
|
||||
HSP Guard authorizes user requests and provides an efficient way to:
|
||||
|
||||
- Manage permissions for individual services/tools
|
||||
- Define roles for easier access control
|
||||
- Validate and authorize users via JWT tokens
|
||||
- Securely integrate with new services during installation
|
||||
- **OIDC Provider**: Central login for your home lab apps
|
||||
- **Admin UI**: Manage apps, users, roles, permissions, and sessions
|
||||
- **API Tokens**: Issue access tokens with embedded roles and permissions
|
||||
- **Flexible Authorization**: Support for roles, permissions, and groups (future)
|
||||
- **App Registration**: Register OAuth/OIDC clients with custom permissions
|
||||
- **Automatic Permission Sync**: Optionally fetch app permissions from `/.well-known/guard-configuration`
|
||||
- **User & Admin Sessions**: See and revoke active user/app sessions
|
||||
- **Pluggable**: Easily integrate new apps and services
|
||||
- **Audit Logging**: Track actions for security and troubleshooting (planned)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Concepts
|
||||
## 🚀 Getting Started
|
||||
|
||||
### 🔐 Permission
|
||||
### 1. **Run HSP Guard**
|
||||
|
||||
Permissions define access to specific features or tools.
|
||||
By default, HSP Guard includes predefined administrative permissions that allow an admin to log in and configure the system.
|
||||
You can run HSP Guard via Docker, Docker Compose, or natively (see below).
|
||||
|
||||
Once logged in, the admin can:
|
||||
### 2. **Register Your First App**
|
||||
|
||||
- Manually create new permissions for specific applications
|
||||
- Allow new applications to register their own permissions
|
||||
- Assign permissions to users, granting them access to corresponding tools
|
||||
1. **Login as admin**
|
||||
2. Go to **Apps → Register New App**
|
||||
3. Enter:
|
||||
- **Name** of your app
|
||||
- **Redirect URIs** (for OIDC/OAuth callbacks)
|
||||
- (Optional) **Permissions** (manual or auto-discovered from the app)
|
||||
4. Save to receive a `client_id` and `client_secret`
|
||||
5. Configure your app to use these for OIDC login
|
||||
|
||||
### 3. **Assign Permissions & Roles**
|
||||
|
||||
- Assign **default roles** to new users automatically (configurable)
|
||||
- Create custom **roles** to bundle permissions (e.g., `FAMILY_MEMBER`)
|
||||
- Assign users to roles and/or groups for flexible access control
|
||||
|
||||
---
|
||||
|
||||
### 🧩 Role
|
||||
## 🏗️ Concepts
|
||||
|
||||
A **Role** is a named collection of permissions (e.g., `GUEST`, `FRIEND`, `FAMILY_MEMBER`) created by the admin.
|
||||
Roles simplify user management by allowing bulk assignment of permissions. Instead of assigning multiple permissions individually, a role bundles them under one label.
|
||||
### 🔑 **Permissions**
|
||||
Fine-grained controls for app features (e.g., `music.play`, `dashboard.edit`).
|
||||
Can be manually defined or auto-discovered from an app’s `.well-known/guard-configuration` endpoint.
|
||||
|
||||
### 🧩 **Roles**
|
||||
Named bundles of permissions (e.g., `GUEST`, `FAMILY_MEMBER`, `ADMIN`).
|
||||
Assign to users/groups for easier management.
|
||||
|
||||
### 👥 **Groups**
|
||||
(Planned) Logical user collections (e.g., “Family”, “Guests”, “Admins”) for batch management of roles/permissions.
|
||||
|
||||
### 👤 **Users**
|
||||
Each user has a unique profile, roles, and group memberships.
|
||||
|
||||
---
|
||||
|
||||
### 👥 Group *(Coming Soon)*
|
||||
## 🔗 OIDC/OAuth Integration
|
||||
|
||||
This feature is planned for future releases. Groups will help organize users or services into logical clusters for simplified access control.
|
||||
**HSP Guard** is a standard-compliant OIDC Provider. Any app supporting OIDC/OAuth can integrate.
|
||||
|
||||
- Register app in admin panel to get `client_id` & `client_secret`
|
||||
- Configure your app’s OIDC integration (see your app’s docs)
|
||||
- Token claims include `permissions` and `roles` for easy authorization
|
||||
|
||||
#### **Example Token Claims**
|
||||
```json
|
||||
{
|
||||
"sub": "123456",
|
||||
"name": "Alex Example",
|
||||
"email": "alex@example.com",
|
||||
"roles": ["GUEST"],
|
||||
"permissions": ["dashboard.view", "music.play"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 API
|
||||
## 📡 **App Permission Discovery**
|
||||
|
||||
### ✅ User Authorization
|
||||
If your app supports permission discovery:
|
||||
- Expose `/.well-known/guard-configuration` endpoint listing available permissions
|
||||
- When registering in HSP Guard, auto-fetch and display for approval
|
||||
|
||||
To verify whether a request is made by a valid and authorized user, applications can require a **JWT token** as part of the request.
|
||||
This token is sent to HSP Guard, which:
|
||||
|
||||
- Validates the token
|
||||
- Returns user details (e.g., ID, name, email) for logging, auditing, or request tracing
|
||||
#### **Example guard-configuration JSON**
|
||||
```json
|
||||
{
|
||||
"permissions": [
|
||||
"dashboard.view",
|
||||
"dashboard.edit",
|
||||
"dashboard.admin"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔑 Permission Checking
|
||||
## 🔄 **User & Admin Sessions**
|
||||
|
||||
Applications can also verify whether a user holds specific permissions before granting access to certain services or features.
|
||||
|
||||
To do this, an app sends:
|
||||
|
||||
- The user's JWT token
|
||||
- A list of required permissions
|
||||
|
||||
HSP Guard checks the user’s assigned permissions and responds with the authorization status.
|
||||
|
||||
> **Best Practice:** Applications should directly integrate with HSP Guard to enforce permission-based access control.
|
||||
- List all active sessions (browser, app, device, timestamp)
|
||||
- Revoke sessions (logout) from user or admin panel
|
||||
|
||||
---
|
||||
|
||||
## 🔄 User Authorization Flow
|
||||
## 📦 **Planned Features & Roadmap**
|
||||
|
||||
When a user tries to access a home lab service that requires authentication:
|
||||
|
||||
1. The application will **offer an authorization URL** to the user.
|
||||
2. The user follows the URL and is taken to the **HSP Guard login page**.
|
||||
3. The user selects or signs into an account they wish to use for that service.
|
||||
4. Once authenticated and authorized, the user is redirected to the **application-defined redirect URL**.
|
||||
5. The application can now:
|
||||
- Retrieve a **JWT token** from the redirect callback
|
||||
- **Optionally cache the session/token** to avoid prompting the user every time
|
||||
|
||||
This process is similar to how external identity providers like **Google Sign-In** or **GitHub OAuth** work — providing a seamless and secure authentication experience for the user.
|
||||
- [ ] **Group Management** for batch assignments
|
||||
- [ ] **Audit Logging** of all admin/user actions
|
||||
- [ ] **Permission Expiry** (time-limited access)
|
||||
- [ ] **Advanced Web UI** (dark mode, mobile)
|
||||
- [ ] **External Identity Providers** (login with Google, GitHub, etc.)
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Integrating New Services & Tools
|
||||
## 🛠️ **Development**
|
||||
|
||||
When a new service or tool is installed:
|
||||
|
||||
1. It provides a configuration file to HSP Guard
|
||||
2. Guard extracts and registers any defined permissions
|
||||
3. These permissions are **isolated** — even if a name overlaps with existing permissions, a prefix is added to avoid conflicts
|
||||
- See [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute!
|
||||
- Pull requests and issues are welcome.
|
||||
|
||||
---
|
||||
|
||||
## 👤 User Registration & Onboarding
|
||||
## 📝 **License**
|
||||
|
||||
New users (e.g., family, friends, guests) must complete a registration process to access your home lab.
|
||||
|
||||
They can:
|
||||
|
||||
- Visit a user-friendly registration webpage
|
||||
- Fill out a form with basic information (name, email, password, etc.)
|
||||
|
||||
Once registered, the admin can assign roles or individual permissions as needed.
|
||||
MIT — open source, for the home lab community.
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Roadmap
|
||||
## 💬 **Feedback**
|
||||
|
||||
- [ ] Group Management
|
||||
- [ ] Web UI Enhancements
|
||||
- [ ] Audit Logging
|
||||
- [ ] Permission Expiry & Time-Based Access
|
||||
Open an [issue](https://github.com/yourusername/hsp-guard/issues) or join the discussion!
|
||||
|
||||
---
|
||||
|
||||
## 📬 Feedback & Contribution
|
||||
|
||||
Feel free to open an issue or pull request if you’d like to contribute or report bugs. HSP Guard is a personal home lab project, but feedback is always welcome!
|
||||
|
90
cmd/hspguard/api/api.go
Normal file
90
cmd/hspguard/api/api.go
Normal file
@ -0,0 +1,90 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/admin"
|
||||
"gitea.local/admin/hspguard/internal/auth"
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"gitea.local/admin/hspguard/internal/oauth"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/storage"
|
||||
"gitea.local/admin/hspguard/internal/user"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
type APIServer struct {
|
||||
addr string
|
||||
repo *repository.Queries
|
||||
storage *storage.FileStorage
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage, cache *cache.Client, cfg *config.AppConfig) *APIServer {
|
||||
return &APIServer{
|
||||
addr: addr,
|
||||
repo: db,
|
||||
storage: minio,
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) Run() error {
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.Logger)
|
||||
|
||||
// workDir, _ := os.Getwd()
|
||||
// staticDir := http.Dir(filepath.Join(workDir, "static"))
|
||||
// FileServer(router, "/static", staticDir)
|
||||
|
||||
oauthHandler := oauth.NewOAuthHandler(s.repo, s.cache, s.cfg)
|
||||
|
||||
router.Route("/api/v1", func(r chi.Router) {
|
||||
userHandler := user.NewUserHandler(s.repo, s.storage, s.cfg)
|
||||
userHandler.RegisterRoutes(r)
|
||||
|
||||
authHandler := auth.NewAuthHandler(s.repo, s.cache, s.cfg)
|
||||
authHandler.RegisterRoutes(r)
|
||||
|
||||
oauthHandler.RegisterRoutes(r)
|
||||
|
||||
adminHandler := admin.New(s.repo, s.cfg)
|
||||
adminHandler.RegisterRoutes(r)
|
||||
})
|
||||
|
||||
router.Get("/.well-known/jwks.json", oauthHandler.WriteJWKS)
|
||||
router.Get("/.well-known/openid-configuration", oauthHandler.OpenIdConfiguration)
|
||||
|
||||
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := "./dist" + r.URL.Path
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
http.ServeFile(w, r, "./dist/index.html")
|
||||
return
|
||||
}
|
||||
http.FileServer(http.Dir("./dist")).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
// Handle unknown routes
|
||||
router.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = fmt.Fprint(w, `{"error": "404 - route not found"}`)
|
||||
})
|
||||
|
||||
router.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
_, _ = fmt.Fprint(w, `{"error": "405 - method not allowed"}`)
|
||||
})
|
||||
|
||||
log.Println("Listening on", s.addr)
|
||||
|
||||
return http.ListenAndServe(s.addr, router)
|
||||
}
|
28
cmd/hspguard/api/file_server.go
Normal file
28
cmd/hspguard/api/file_server.go
Normal file
@ -0,0 +1,28 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func FileServer(r chi.Router, path string, root http.FileSystem) {
|
||||
if strings.ContainsAny(path, "{}*") {
|
||||
panic("FileServer does not permit any URL parameters.")
|
||||
}
|
||||
|
||||
if path != "/" && path[len(path)-1] != '/' {
|
||||
r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP)
|
||||
path += "/"
|
||||
}
|
||||
path += "*"
|
||||
|
||||
r.Get(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
rctx := chi.RouteContext(r.Context())
|
||||
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
|
||||
fs := http.StripPrefix(pathPrefix, http.FileServer(root))
|
||||
fs.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
@ -2,46 +2,52 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gitea.local/admin/hspguard/cmd/hspguard/api"
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"gitea.local/admin/hspguard/internal/storage"
|
||||
"gitea.local/admin/hspguard/internal/user"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
fmt.Println("Hello, World!")
|
||||
|
||||
conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
err := godotenv.Load()
|
||||
if err != nil && os.Getenv("ENV") != "production" {
|
||||
log.Fatalln("WARNING: .env file not found. Skipping...")
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
repo := repository.New(conn)
|
||||
var cfg config.AppConfig
|
||||
|
||||
// id, err := repo.InsertUser(ctx, repository.InsertUserParams{
|
||||
// Email: "test@test.com",
|
||||
// FullName: "Test John",
|
||||
// PasswordHash: "fjeijh3uhit5hg45bjkf4ghy8ft548",
|
||||
// IsAdmin: true,
|
||||
// })
|
||||
// if err != nil {
|
||||
// log.Fatalln("ERR: Failed to insert user:", err)
|
||||
// return
|
||||
// }
|
||||
|
||||
users, _ := repo.FindAllUsers(ctx)
|
||||
|
||||
dump, err := json.Marshal(users)
|
||||
err = config.LoadEnv(&cfg)
|
||||
if err != nil {
|
||||
log.Fatalln("ERR: Failed to marshal response:", err)
|
||||
log.Fatal(err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(string(dump))
|
||||
ctx := context.Background()
|
||||
|
||||
conn, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalln("ERR: Failed to connect to db:", err)
|
||||
return
|
||||
}
|
||||
|
||||
repo := repository.New(conn)
|
||||
|
||||
fStorage := storage.New(&cfg)
|
||||
|
||||
cache := cache.NewClient(&cfg)
|
||||
|
||||
user.EnsureAdminUser(ctx, &cfg, repo)
|
||||
|
||||
server := api.NewAPIServer(fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), repo, fStorage, cache, &cfg)
|
||||
if err := server.Run(); err != nil {
|
||||
log.Fatalln("ERR: Failed to start server:", err)
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
@ -10,3 +9,17 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
cache:
|
||||
image: redis:7.2 # or newer
|
||||
container_name: guard-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
- ./redis.conf:/usr/local/etc/redis/redis.conf
|
||||
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
driver: local
|
||||
|
31
go.mod
31
go.mod
@ -3,12 +3,35 @@ module gitea.local/admin/hspguard
|
||||
go 1.24.3
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx v3.6.2+incompatible // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/minio/crc64nvme v1.0.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.92 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/redis/go-redis/v9 v9.10.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
)
|
||||
|
57
go.sum
57
go.sum
@ -1,23 +1,72 @@
|
||||
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 h1:5/KfwL9TS8yNtUSunutqifcSC8rdX9PNdvbSsw/X/lQ=
|
||||
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406/go.mod h1:s+GCtuP4kZNxh1WGoqdWI1+PbluBcycrMMWuKQ9e5Nk=
|
||||
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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
|
||||
github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
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.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
|
||||
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
|
||||
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
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/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs=
|
||||
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
312
internal/admin/apiservices.go
Normal file
312
internal/admin/apiservices.go
Normal file
@ -0,0 +1,312 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) GetApiServices(w http.ResponseWriter, r *http.Request) {
|
||||
services, err := h.repo.ListApiServices(r.Context())
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to list api services from db:", err)
|
||||
web.Error(w, "failed to get api services", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
apiServices := make([]types.ApiServiceDTO, 0)
|
||||
|
||||
for _, service := range services {
|
||||
apiServices = append(apiServices, types.NewApiServiceDTO(service))
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []types.ApiServiceDTO `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Items: apiServices,
|
||||
Count: len(apiServices),
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type AddServiceRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type ApiServiceCredentials struct {
|
||||
ClientId string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
|
||||
var req AddServiceRequest
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "failed to parse request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
web.Error(w, "name is required for an api service", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
clientId, err := util.GenerateClientID()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate client id", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
clientSecret, err := util.GenerateClientSecret()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate client secret", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
hashSecret, err := util.HashPassword(clientSecret)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to create client secret", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
params := repository.CreateApiServiceParams{
|
||||
ClientID: clientId,
|
||||
ClientSecret: hashSecret,
|
||||
Name: req.Name,
|
||||
RedirectUris: req.RedirectUris,
|
||||
Scopes: req.Scopes,
|
||||
GrantTypes: req.GrantTypes,
|
||||
IsActive: req.IsActive,
|
||||
}
|
||||
|
||||
if req.Description != "" {
|
||||
params.Description = &req.Description
|
||||
}
|
||||
|
||||
service, err := h.repo.CreateApiService(r.Context(), params)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to create new api service", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
service.ClientSecret = clientSecret
|
||||
|
||||
type Response struct {
|
||||
Service types.ApiServiceDTO `json:"service"`
|
||||
Credentials ApiServiceCredentials `json:"credentials"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Service: types.NewApiServiceDTO(service),
|
||||
Credentials: ApiServiceCredentials{
|
||||
ClientId: service.ClientID,
|
||||
ClientSecret: service.ClientSecret,
|
||||
},
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetApiService(w http.ResponseWriter, r *http.Request) {
|
||||
serviceId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(serviceId)
|
||||
if err != nil {
|
||||
web.Error(w, "service id provided is not a valid uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(types.NewApiServiceDTO(service)); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetApiServiceCID(w http.ResponseWriter, r *http.Request) {
|
||||
clientId := chi.URLParam(r, "client_id")
|
||||
|
||||
service, err := h.repo.GetApiServiceCID(r.Context(), clientId)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided client id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(types.NewApiServiceDTO(service)); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RegenerateApiServiceSecret(w http.ResponseWriter, r *http.Request) {
|
||||
serviceId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(serviceId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
clientSecret, err := util.GenerateClientSecret()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate client secret", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateClientSecret(r.Context(), repository.UpdateClientSecretParams{
|
||||
ClientID: service.ClientID,
|
||||
ClientSecret: clientSecret,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to update client secret for service", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(ApiServiceCredentials{
|
||||
ClientId: service.ClientID,
|
||||
ClientSecret: clientSecret,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to send credentials", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateApiServiceRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateApiService(w http.ResponseWriter, r *http.Request) {
|
||||
serviceId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(serviceId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateApiServiceRequest
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "missing required fields to update service", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
web.Error(w, "service name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Scopes) == 0 {
|
||||
web.Error(w, "at least 1 scope is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.repo.UpdateApiService(r.Context(), repository.UpdateApiServiceParams{
|
||||
ClientID: service.ClientID,
|
||||
Name: req.Name,
|
||||
Description: &req.Description,
|
||||
RedirectUris: req.RedirectUris,
|
||||
Scopes: req.Scopes,
|
||||
GrantTypes: req.GrantTypes,
|
||||
})
|
||||
if err != nil {
|
||||
web.Error(w, "failed to update api service", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(types.NewApiServiceDTO(updated)); err != nil {
|
||||
web.Error(w, "failed to send updated api service", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ToggleApiService(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
|
||||
serviceId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(serviceId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if service.IsActive {
|
||||
log.Println("INFO: Service is active. Deactivating...")
|
||||
err = h.repo.DeactivateApiService(r.Context(), service.ClientID)
|
||||
} else {
|
||||
log.Println("INFO: Service is inactive. Activating...")
|
||||
err = h.repo.ActivateApiService(r.Context(), service.ClientID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to toggle api service (cid: %s): %v\n", service.ClientID, err)
|
||||
web.Error(w, "failed to toggle api service", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
47
internal/admin/routes.go
Normal file
47
internal/admin/routes.go
Normal file
@ -0,0 +1,47 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
repo *repository.Queries
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func New(repo *repository.Queries, cfg *config.AppConfig) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
repo,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Route("/admin", func(r chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
adminMiddleware := imiddleware.NewAdminMiddleware(h.repo)
|
||||
r.Use(authMiddleware.Runner, adminMiddleware.Runner)
|
||||
|
||||
r.Get("/api-services", h.GetApiServices)
|
||||
r.Get("/api-services/{id}", h.GetApiService)
|
||||
r.Post("/api-services", h.AddApiService)
|
||||
r.Patch("/api-services/{id}", h.RegenerateApiServiceSecret)
|
||||
r.Put("/api-services/{id}", h.UpdateApiService)
|
||||
r.Patch("/api-services/toggle/{id}", h.ToggleApiService)
|
||||
|
||||
r.Get("/users", h.GetUsers)
|
||||
r.Post("/users", h.CreateUser)
|
||||
r.Get("/users/{id}", h.GetUser)
|
||||
|
||||
r.Get("/user-sessions", h.GetUserSessions)
|
||||
r.Patch("/user-sessions/revoke/{id}", h.RevokeUserSession)
|
||||
|
||||
r.Get("/service-sessions", h.GetServiceSessions)
|
||||
r.Patch("/service-sessions/revoke/{id}", h.RevokeUserSession)
|
||||
})
|
||||
|
||||
router.Get("/api-services/client/{client_id}", h.GetApiServiceCID)
|
||||
}
|
182
internal/admin/sessions.go
Normal file
182
internal/admin/sessions.go
Normal file
@ -0,0 +1,182 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GetSessionsParams struct {
|
||||
PageSize int `json:"size"`
|
||||
Page int `json:"page"`
|
||||
// TODO: More filtering possibilities like onlyActive, expired, not-expired etc.
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserSessions(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
params := GetSessionsParams{}
|
||||
|
||||
if pageSize, err := strconv.Atoi(q.Get("size")); err == nil {
|
||||
params.PageSize = pageSize
|
||||
} else {
|
||||
params.PageSize = 15
|
||||
}
|
||||
|
||||
if page, err := strconv.Atoi(q.Get("page")); err == nil {
|
||||
params.Page = page
|
||||
} else {
|
||||
web.Error(w, "page is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := h.repo.GetUserSessions(r.Context(), repository.GetUserSessionsParams{
|
||||
Limit: int32(params.PageSize),
|
||||
Offset: int32(params.Page-1) * int32(params.PageSize),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to read user sessions from db:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalSessions, err := h.repo.GetUserSessionsCount(r.Context())
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to get total count of user sessions:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mapped := make([]*types.UserSessionDTO, 0)
|
||||
|
||||
for _, session := range sessions {
|
||||
mapped = append(mapped, types.NewUserSessionDTO(&session))
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []*types.UserSessionDTO `json:"items"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
response := Response{
|
||||
Items: mapped,
|
||||
Page: params.Page,
|
||||
TotalPages: int(math.Ceil(float64(totalSessions) / float64(params.PageSize))),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Println("ERR: Failed to encode sessions in response:", err)
|
||||
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RevokeUserSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(sessionId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.RevokeUserSession(r.Context(), parsed); err != nil {
|
||||
log.Println("ERR: Failed to revoke user session:", err)
|
||||
web.Error(w, "failed to revoke user session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{\"success\":true}"))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetServiceSessions(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
params := GetSessionsParams{}
|
||||
|
||||
if pageSize, err := strconv.Atoi(q.Get("size")); err == nil {
|
||||
params.PageSize = pageSize
|
||||
} else {
|
||||
params.PageSize = 15
|
||||
}
|
||||
|
||||
if page, err := strconv.Atoi(q.Get("page")); err == nil {
|
||||
params.Page = page
|
||||
} else {
|
||||
web.Error(w, "page is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := h.repo.GetServiceSessions(r.Context(), repository.GetServiceSessionsParams{
|
||||
Limit: int32(params.PageSize),
|
||||
Offset: int32(params.Page-1) * int32(params.PageSize),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to read api sessions from db:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalSessions, err := h.repo.GetServiceSessionsCount(r.Context())
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to get total count of service sessions:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mapped := make([]*types.ServiceSessionDTO, 0)
|
||||
|
||||
for _, session := range sessions {
|
||||
mapped = append(mapped, types.NewServiceSessionDTO(&session))
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []*types.ServiceSessionDTO `json:"items"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
response := Response{
|
||||
Items: mapped,
|
||||
Page: params.Page,
|
||||
TotalPages: int(math.Ceil(float64(totalSessions) / float64(params.PageSize))),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Println("ERR: Failed to encode sessions in response:", err)
|
||||
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RevokeServiceSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(sessionId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.RevokeServiceSession(r.Context(), parsed); err != nil {
|
||||
log.Println("ERR: Failed to revoke service session:", err)
|
||||
web.Error(w, "failed to revoke service session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{\"success\":true}"))
|
||||
}
|
165
internal/admin/users.go
Normal file
165
internal/admin/users.go
Normal file
@ -0,0 +1,165 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "failed to get access information", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.repo.FindAdminUsers(r.Context(), &user.ID)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to query users from db:", err)
|
||||
web.Error(w, "failed to get all users", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []types.UserDTO `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
var items []types.UserDTO
|
||||
|
||||
for _, user := range users {
|
||||
items = append(items, types.NewUserDTO(&user))
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(&Response{
|
||||
Items: items,
|
||||
Count: len(items),
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to send response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
userId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(userId)
|
||||
if err != nil {
|
||||
web.Error(w, "user id provided is not a valid uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(types.NewUserDTO(&user)); err != nil {
|
||||
web.Error(w, "failed to encode user dto", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "failed to get access information", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateUserRequest
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email == "" {
|
||||
web.Error(w, "email is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.FullName == "" {
|
||||
web.Error(w, "full name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
web.Error(w, "password is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.repo.FindUserEmail(r.Context(), req.Email)
|
||||
if err == nil {
|
||||
web.Error(w, "user with provided email already exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := util.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to hash password for new user:", err)
|
||||
web.Error(w, "failed to create user account", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
params := repository.InsertUserParams{
|
||||
Email: req.Email,
|
||||
FullName: req.FullName,
|
||||
PasswordHash: hash,
|
||||
IsAdmin: false,
|
||||
CreatedBy: &user.ID,
|
||||
}
|
||||
|
||||
log.Println("INFO: params for user creation:", params)
|
||||
|
||||
id, err := h.repo.InsertUser(r.Context(), params)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to insert user into database:", err)
|
||||
web.Error(w, "failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(Response{
|
||||
ID: id.String(),
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
107
internal/auth/login.go
Normal file
107
internal/auth/login.go
Normal file
@ -0,0 +1,107 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
)
|
||||
|
||||
type LoginParams struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||
var params LoginParams
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(¶ms); err != nil {
|
||||
web.Error(w, "failed to parse request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if params.Email == "" || params.Password == "" {
|
||||
web.Error(w, "missing required fields", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: looking for user with following params: %#v\n", params)
|
||||
|
||||
user, err := h.repo.FindUserEmail(r.Context(), params.Email)
|
||||
if err != nil {
|
||||
log.Printf("DEBUG: No user found with '%s' email: %v\n", params.Email, err)
|
||||
web.Error(w, "email or/and password are incorrect", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !util.VerifyPassword(params.Password, user.PasswordHash) {
|
||||
log.Printf("DEBUG: Incorrect password '%s' for '%s' email: %v\n", params.Password, params.Email, err)
|
||||
web.Error(w, "email or/and password are incorrect", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
access, refresh, err := h.signTokens(&user)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
ipAddr := util.GetClientIP(r)
|
||||
deviceInfo := util.BuildDeviceInfo(userAgent, ipAddr)
|
||||
|
||||
// Create User Session
|
||||
session, err := h.repo.CreateUserSession(r.Context(), repository.CreateUserSessionParams{
|
||||
UserID: user.ID,
|
||||
SessionType: "user",
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
LastActive: nil,
|
||||
IpAddress: &ipAddr,
|
||||
UserAgent: &userAgent,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
DeviceInfo: deviceInfo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to create user session after logging in: %v\n", err)
|
||||
}
|
||||
|
||||
log.Printf("INFO: User session created for '%s' with '%s' id\n", user.Email, session.ID.String())
|
||||
|
||||
if err := h.repo.UpdateLastLogin(r.Context(), user.ID); err != nil {
|
||||
web.Error(w, "failed to update user's last login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
type Response struct {
|
||||
AccessToken string `json:"access"`
|
||||
RefreshToken string `json:"refresh"`
|
||||
// fields required for UI in account selector, e.g. email, full name and avatar
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Id string `json:"id"`
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
// Avatar
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
AccessToken: access.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
Id: user.ID.String(),
|
||||
ProfilePicture: user.ProfilePicture,
|
||||
// Avatar
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
31
internal/auth/profile.go
Normal file
31
internal/auth/profile.go
Normal file
@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(types.NewUserDTO(&user)); err != nil {
|
||||
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
122
internal/auth/refresh.go
Normal file
122
internal/auth/refresh.go
Normal file
@ -0,0 +1,122 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
web.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, "Bearer ")
|
||||
if len(parts) != 2 {
|
||||
web.Error(w, "invalid auth header format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
var userClaims types.UserClaims
|
||||
|
||||
token, err := util.VerifyToken(tokenStr, h.cfg.Jwt.PublicKey, &userClaims)
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
expire, err := userClaims.GetExpirationTime()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(expire.Time) {
|
||||
web.Error(w, "token is expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := uuid.Parse(userClaims.Subject)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to parsej user id from token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), userId)
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
access, refresh, err := h.signTokens(&user)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jti, err := uuid.Parse(userClaims.ID)
|
||||
if session, err := h.repo.GetUserSessionByRefreshJTI(r.Context(), &jti); err != nil {
|
||||
log.Printf("WARN: No existing user session found for user with '%s' email (jti: '%s'): %v\n", user.Email, userClaims.ID, err)
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
ipAddr := util.GetClientIP(r)
|
||||
deviceInfo := util.BuildDeviceInfo(userAgent, ipAddr)
|
||||
|
||||
// Create User Session
|
||||
session, err := h.repo.CreateUserSession(r.Context(), repository.CreateUserSessionParams{
|
||||
UserID: user.ID,
|
||||
SessionType: "user",
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
LastActive: nil,
|
||||
IpAddress: &ipAddr,
|
||||
UserAgent: &userAgent,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
DeviceInfo: deviceInfo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to create user session after logging in: %v\n", err)
|
||||
}
|
||||
|
||||
log.Printf("INFO: User session created for '%s' with '%s' id\n", user.Email, session.ID.String())
|
||||
} else {
|
||||
err := h.repo.UpdateSessionTokens(r.Context(), repository.UpdateSessionTokensParams{
|
||||
ID: session.ID,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to update user session with '%s' id: %v\n", session.ID.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
AccessToken string `json:"access"`
|
||||
RefreshToken string `json:"refresh"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
AccessToken: access.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
91
internal/auth/routes.go
Normal file
91
internal/auth/routes.go
Normal file
@ -0,0 +1,91 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
repo *repository.Queries
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func (h *AuthHandler) signTokens(user *repository.User) (*types.SignedToken, *types.SignedToken, error) {
|
||||
accessExpiresAt := time.Now().Add(15 * time.Minute)
|
||||
accessJTI := uuid.New()
|
||||
|
||||
accessClaims := types.UserClaims{
|
||||
UserEmail: user.Email,
|
||||
IsAdmin: user.IsAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(accessExpiresAt),
|
||||
ID: accessJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
accessToken, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
refreshExpiresAt := time.Now().Add(30 * 24 * time.Hour)
|
||||
refreshJTI := uuid.New()
|
||||
|
||||
refreshClaims := types.UserClaims{
|
||||
UserEmail: user.Email,
|
||||
IsAdmin: user.IsAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExpiresAt),
|
||||
ID: refreshJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
refreshToken, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return types.NewSignedToken(accessToken, accessExpiresAt, accessJTI), types.NewSignedToken(refreshToken, refreshExpiresAt, refreshJTI), nil
|
||||
}
|
||||
|
||||
func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
repo,
|
||||
cache,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
|
||||
api.Route("/auth", func(r chi.Router) {
|
||||
r.Group(func(protected chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
protected.Use(authMiddleware.Runner)
|
||||
|
||||
protected.Get("/profile", h.getProfile)
|
||||
protected.Post("/email", h.requestEmailOtp)
|
||||
protected.Post("/email/otp", h.confirmOtp)
|
||||
protected.Post("/verify", h.finishVerification)
|
||||
protected.Post("/signout", h.signOut)
|
||||
})
|
||||
|
||||
r.Post("/login", h.login)
|
||||
r.Post("/refresh", h.refreshToken)
|
||||
})
|
||||
}
|
40
internal/auth/signout.go
Normal file
40
internal/auth/signout.go
Normal file
@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) signOut(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{\"status\": \"ok\"}"))
|
||||
}()
|
||||
|
||||
jti, ok := util.GetRequestJTI(r.Context())
|
||||
if !ok {
|
||||
log.Println("WARN: No JTI found in request")
|
||||
return
|
||||
}
|
||||
|
||||
jtiId, err := uuid.Parse(jti)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse jti '%s' as v4 uuid: %v\n", jti, err)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.repo.GetUserSessionByAccessJTI(r.Context(), &jtiId)
|
||||
if err != nil {
|
||||
log.Printf("WARN: Could not find session by jti id '%s': %v\n", jtiId.String(), err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.RevokeUserSession(r.Context(), session.ID); err != nil {
|
||||
log.Printf("ERR: Failed to revoke session with '%s' id: %v\n", session.ID.String(), err)
|
||||
} else {
|
||||
log.Printf("INFO: Revoked session with jti = '%s' and session id = '%s'\n", jtiId.String(), session.ID.String())
|
||||
}
|
||||
}
|
126
internal/auth/verify.go
Normal file
126
internal/auth/verify.go
Normal file
@ -0,0 +1,126 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) requestEmailOtp(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
web.Error(w, "email is already verified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
number := rand.Intn(1000000) // 0 to 999999
|
||||
padded := fmt.Sprintf("%06d", number) // Always 6 characters
|
||||
|
||||
if _, err := h.cache.Set(r.Context(), fmt.Sprintf("otp-%s", user.ID.String()), padded, 5*time.Minute).Result(); err != nil {
|
||||
log.Println("ERR: Failed to save OTP in cache:", err)
|
||||
web.Error(w, "failed to generate otp", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("INFO: Saved OTP %s\n", padded)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
type ConfirmOtpRequest struct {
|
||||
OTP string `json:"otp"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) confirmOtp(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
web.Error(w, "email is already verified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req ConfirmOtpRequest
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
val, err := h.cache.Get(r.Context(), fmt.Sprintf("otp-%s", user.ID.String())).Result()
|
||||
if err != nil {
|
||||
web.Error(w, "otp verification session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("INFO: Comparing OTP %s == %s\n", req.OTP, val)
|
||||
|
||||
if req.OTP == val {
|
||||
err := h.repo.UserVerifyEmail(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to update email_verified:", err)
|
||||
web.Error(w, "failed to verify email", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
web.Error(w, "otp verification failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) finishVerification(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.EmailVerified || !user.AvatarVerified {
|
||||
web.Error(w, "finish other verification steps before final verify", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UserVerifyComplete(r.Context(), user.ID); err != nil {
|
||||
log.Println("ERR: Failed to update verified on user:", err)
|
||||
web.Error(w, "failed to verify user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
84
internal/cache/mod.go
vendored
Normal file
84
internal/cache/mod.go
vendored
Normal file
@ -0,0 +1,84 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
rClient *redis.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg *config.AppConfig) *Client {
|
||||
opts, err := redis.ParseURL(cfg.RedisURL)
|
||||
if err != nil {
|
||||
log.Fatalln("ERR: Failed to get redis options:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
client := redis.NewClient(opts)
|
||||
|
||||
return &Client{
|
||||
rClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
type OAuthCode struct {
|
||||
ClientID string `json:"client_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
type SaveAuthCodeParams struct {
|
||||
AuthCode string
|
||||
UserID string
|
||||
ClientID string
|
||||
Nonce string
|
||||
}
|
||||
|
||||
func (c *Client) Set(ctx context.Context, key string, value any, expiration time.Duration) *redis.StatusCmd {
|
||||
return c.rClient.Set(ctx, key, value, expiration)
|
||||
}
|
||||
|
||||
func (c *Client) SaveAuthCode(ctx context.Context, params *SaveAuthCodeParams) error {
|
||||
code := OAuthCode{
|
||||
ClientID: params.ClientID,
|
||||
UserID: params.UserID,
|
||||
Nonce: params.Nonce,
|
||||
}
|
||||
row, err := json.Marshal(&code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Set(ctx, fmt.Sprintf("oauth.%s", params.AuthCode), string(row), 5*time.Minute).Err()
|
||||
}
|
||||
|
||||
func (c *Client) GetAuthCode(ctx context.Context, authCode string) (*OAuthCode, error) {
|
||||
row, err := c.Get(ctx, fmt.Sprintf("oauth.%s", authCode)).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(row) == 0 {
|
||||
return nil, fmt.Errorf("no auth params found under %s", authCode)
|
||||
}
|
||||
|
||||
var parsed OAuthCode
|
||||
|
||||
if err := json.Unmarshal([]byte(row), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func (c *Client) Get(ctx context.Context, key string) *redis.StringCmd {
|
||||
return c.rClient.Get(ctx, key)
|
||||
}
|
7
internal/config/admin.go
Normal file
7
internal/config/admin.go
Normal file
@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
type AdminConfig struct {
|
||||
Name string `env:"GUARD_ADMIN_NAME" default:"Admin"`
|
||||
Email string `env:"GUARD_ADMIN_EMAIL" required:"true"`
|
||||
Password string `env:"GUARD_ADMIN_PASSWORD" required:"true"`
|
||||
}
|
7
internal/config/jwt.go
Normal file
7
internal/config/jwt.go
Normal file
@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
type JwtConfig struct {
|
||||
PrivateKey string `env:"GUARD_JWT_PRIVATE" required:"true"`
|
||||
PublicKey string `env:"GUARD_JWT_PUBLIC" required:"true"`
|
||||
KID string `env:"GUARD_JWT_KID" default:"guard-rsa"`
|
||||
}
|
7
internal/config/minio.go
Normal file
7
internal/config/minio.go
Normal file
@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
type MinioConfig struct {
|
||||
Endpoint string `env:"GUARD_MINIO_ENDPOINT" default:"localhost:9000"`
|
||||
AccessKey string `env:"GUARD_MINIO_ACCESS_KEY" required:"true"`
|
||||
SecretKey string `env:"GUARD_MINIO_SECRET_KEY" required:"true"`
|
||||
}
|
100
internal/config/mod.go
Normal file
100
internal/config/mod.go
Normal file
@ -0,0 +1,100 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AppConfig struct {
|
||||
Port string `env:"GUARD_PORT" default:"3001"`
|
||||
Host string `env:"GUARD_HOST" default:"127.0.0.1"`
|
||||
Uri string `env:"GUARD_URI" default:"http://127.0.0.1:3001"`
|
||||
DatabaseURL string `env:"GUARD_DB_URL" required:"true"`
|
||||
RedisURL string `env:"GUARD_REDIS_URL" default:"redis://localhost:6379/0"`
|
||||
Admin AdminConfig
|
||||
Jwt JwtConfig
|
||||
Minio MinioConfig
|
||||
}
|
||||
|
||||
func LoadEnv(target any) error {
|
||||
v := reflect.ValueOf(target)
|
||||
if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
|
||||
return &InvalidTargetError{}
|
||||
}
|
||||
return loadStruct(v.Elem(), "")
|
||||
}
|
||||
|
||||
type InvalidTargetError struct{}
|
||||
|
||||
func (e *InvalidTargetError) Error() string {
|
||||
return "target must be a pointer to a struct"
|
||||
}
|
||||
|
||||
var ErrMissingRequiredEnv = errors.New("missing required environment variable")
|
||||
|
||||
func loadStruct(v reflect.Value, prefix string) error {
|
||||
t := v.Type()
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
valueField := v.Field(i)
|
||||
|
||||
if !valueField.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
envKey := field.Tag.Get("env")
|
||||
if envKey == "" {
|
||||
envKey = strings.ToUpper(prefix + "_" + field.Name)
|
||||
envKey = strings.TrimPrefix(envKey, "_")
|
||||
}
|
||||
|
||||
required := field.Tag.Get("required") == "true"
|
||||
defaultVal := field.Tag.Get("default")
|
||||
|
||||
if field.Type.Kind() == reflect.Struct {
|
||||
err := loadStruct(valueField, envKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
envVal := os.Getenv(envKey)
|
||||
|
||||
if envVal == "" {
|
||||
if defaultVal != "" {
|
||||
envVal = defaultVal
|
||||
} else if required {
|
||||
return fmt.Errorf("%w: %s", ErrMissingRequiredEnv, envKey)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
switch field.Type.Kind() {
|
||||
case reflect.String:
|
||||
valueField.SetString(envVal)
|
||||
case reflect.Int, reflect.Int64:
|
||||
i, err := strconv.ParseInt(envVal, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid int for %s: %w", envKey, err)
|
||||
}
|
||||
valueField.SetInt(i)
|
||||
case reflect.Bool:
|
||||
b, err := strconv.ParseBool(envVal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid bool for %s: %w", envKey, err)
|
||||
}
|
||||
valueField.SetBool(b)
|
||||
default:
|
||||
return fmt.Errorf("unsupported type for field %s", field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
47
internal/middleware/admin.go
Normal file
47
internal/middleware/admin.go
Normal file
@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AdminMiddleware struct {
|
||||
repo *repository.Queries
|
||||
}
|
||||
|
||||
func NewAdminMiddleware(repo *repository.Queries) *AdminMiddleware {
|
||||
return &AdminMiddleware{
|
||||
repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AdminMiddleware) Runner(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
log.Println("ERR: Could not get user id from request")
|
||||
web.Error(w, "not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
log.Println("ERR: User with provided id does not exist:", userId)
|
||||
web.Error(w, "not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsAdmin {
|
||||
log.Println("INFO: User is not admin")
|
||||
web.Error(w, "no priviligies to access this resource", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
91
internal/middleware/auth.go
Normal file
91
internal/middleware/auth.go
Normal file
@ -0,0 +1,91 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuthMiddleware struct {
|
||||
cfg *config.AppConfig
|
||||
repo *repository.Queries
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(cfg *config.AppConfig, repo *repository.Queries) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
cfg,
|
||||
repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) Runner(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
web.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, "Bearer ")
|
||||
if len(parts) != 2 {
|
||||
web.Error(w, "invalid auth header format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
var userClaims types.UserClaims
|
||||
|
||||
token, err := util.VerifyToken(tokenStr, m.cfg.Jwt.PublicKey, &userClaims)
|
||||
if err != nil || !token.Valid {
|
||||
web.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: redis caching
|
||||
parsed, err := uuid.Parse(userClaims.ID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse token JTI '%s': %v\n", userClaims.ID, err)
|
||||
web.Error(w, "failed to get session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
session, err := m.repo.GetUserSessionByAccessJTI(r.Context(), &parsed)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to find session with '%s' JTI: %v\n", parsed.String(), err)
|
||||
web.Error(w, "no session found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !session.IsActive {
|
||||
log.Printf("INFO: Inactive session trying to authorize: %s\n", session.AccessTokenID)
|
||||
web.Error(w, "no session found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.Subject)
|
||||
ctx = context.WithValue(ctx, types.JTIKey, userClaims.ID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func WithSkipper(mw func(http.Handler) http.Handler, excludedPaths ...string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, path := range excludedPaths {
|
||||
if strings.HasPrefix(r.URL.Path, path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
mw(next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
44
internal/oauth/authorize.go
Normal file
44
internal/oauth/authorize.go
Normal file
@ -0,0 +1,44 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
)
|
||||
|
||||
// client_id=gitea-client&redirect_uri=https://git.adalspace.com/user/oauth2/Home%20Guard/callback&response_type=code&scope=openid&state=4c3b4a25-9cf9-4b18-afc0-270e1078eb40
|
||||
func (h *OAuthHandler) AuthorizeClient(w http.ResponseWriter, r *http.Request) {
|
||||
redirectUri := r.URL.Query().Get("redirect_uri")
|
||||
if redirectUri == "" {
|
||||
web.Error(w, "redirect_uri is missing in request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
clientId := r.URL.Query().Get("client_id")
|
||||
if clientId == "" {
|
||||
uri := fmt.Sprintf("%s?error=invalid_request&error_description=ClientID+is+missing", redirectUri)
|
||||
if state != "" {
|
||||
uri += "&state=" + state
|
||||
}
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
scopes := strings.Split(strings.TrimSpace(r.URL.Query().Get("scope")), " ")
|
||||
|
||||
if uri, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{
|
||||
ClientID: clientId,
|
||||
RedirectURI: &redirectUri,
|
||||
State: state,
|
||||
Scopes: &scopes,
|
||||
}); err != nil {
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/auth?%s", r.URL.Query().Encode()), http.StatusFound)
|
||||
}
|
58
internal/oauth/client.go
Normal file
58
internal/oauth/client.go
Normal file
@ -0,0 +1,58 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type VerifyOAuthClientParams struct {
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURI *string `json:"redirect_uri"`
|
||||
State string `json:"state"`
|
||||
Scopes *[]string `json:"scopes"`
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) verifyOAuthClient(ctx context.Context, params *VerifyOAuthClientParams) (string, error) {
|
||||
client, err := h.repo.GetApiServiceCID(ctx, params.ClientID)
|
||||
if err != nil {
|
||||
uri := fmt.Sprintf("%s?error=access_denied&error_description=Service+not+authorized", *params.RedirectURI)
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("target oauth service with client id '%s' is not registered", params.ClientID)
|
||||
}
|
||||
|
||||
if !client.IsActive {
|
||||
uri := fmt.Sprintf("%s?error=temporarily_unavailable&error_description=Service+not+active", *params.RedirectURI)
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("target oauth service with client id '%s' is not available", client.ClientID)
|
||||
}
|
||||
|
||||
if params.Scopes != nil {
|
||||
for _, scope := range *params.Scopes {
|
||||
if !slices.Contains(client.Scopes, scope) {
|
||||
uri := fmt.Sprintf("%s?error=invalid_scope&error_description=Scope+%s+is+not+allowed", *params.RedirectURI, strings.ReplaceAll(scope, " ", "+"))
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("unallowed scope '%s' requested", scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if params.RedirectURI != nil {
|
||||
if !slices.Contains(client.RedirectUris, *params.RedirectURI) {
|
||||
uri := fmt.Sprintf("%s?error=invalid_request&error_description=Redirect+URI+is+not+allowed", *params.RedirectURI)
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("redirect uri '%s' is unallowed", *params.RedirectURI)
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
89
internal/oauth/code.go
Normal file
89
internal/oauth/code.go
Normal file
@ -0,0 +1,89 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Nonce string `json:"nonce"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "nonce field is required in request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{
|
||||
ClientID: req.ClientID,
|
||||
RedirectURI: nil,
|
||||
State: "",
|
||||
Scopes: nil,
|
||||
}); err != nil {
|
||||
web.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 32)
|
||||
_, err = rand.Read(buf)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to generate auth code:", err)
|
||||
web.Error(w, "failed to create authorization code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
authCode := base64.RawURLEncoding.EncodeToString(buf)
|
||||
|
||||
params := cache.SaveAuthCodeParams{
|
||||
AuthCode: authCode,
|
||||
UserID: user.ID.String(),
|
||||
ClientID: req.ClientID,
|
||||
Nonce: req.Nonce,
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: Saving auth code session with params: %#v\n", params)
|
||||
|
||||
if err := h.cache.SaveAuthCode(r.Context(), ¶ms); err != nil {
|
||||
log.Println("ERR: Failed to save auth code in redis:", err)
|
||||
web.Error(w, "failed to generate auth code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Code: authCode,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
36
internal/oauth/jwks.go
Normal file
36
internal/oauth/jwks.go
Normal file
@ -0,0 +1,36 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
)
|
||||
|
||||
func (h *OAuthHandler) WriteJWKS(w http.ResponseWriter, r *http.Request) {
|
||||
pubKey, err := util.ParseBase64PublicKey(h.cfg.Jwt.PublicKey)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to parse public key", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
n := base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes())
|
||||
e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) // 65537 = 0x010001
|
||||
|
||||
jwks := map[string]interface{}{
|
||||
"keys": []map[string]string{
|
||||
{
|
||||
"kty": "RSA",
|
||||
"kid": "my-rsa-key-1",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"n": n,
|
||||
"e": e,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(jwks)
|
||||
}
|
39
internal/oauth/openid.go
Normal file
39
internal/oauth/openid.go
Normal file
@ -0,0 +1,39 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
)
|
||||
|
||||
func (h *OAuthHandler) OpenIdConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||
type Response struct {
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
Issuer string `json:"issuer"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
TokenEndpoint: h.cfg.Uri + "/api/v1/oauth/token",
|
||||
AuthorizationEndpoint: h.cfg.Uri + "/api/v1/oauth/authorize",
|
||||
JwksURI: h.cfg.Uri + "/.well-known/jwks.json",
|
||||
Issuer: h.cfg.Uri,
|
||||
EndSessionEndpoint: h.cfg.Uri + "/api/v1/oauth/logout",
|
||||
GrantTypesSupported: []string{
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
},
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
36
internal/oauth/routes.go
Normal file
36
internal/oauth/routes.go
Normal file
@ -0,0 +1,36 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type OAuthHandler struct {
|
||||
repo *repository.Queries
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func NewOAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *OAuthHandler {
|
||||
return &OAuthHandler{
|
||||
repo,
|
||||
cache,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Route("/oauth", func(r chi.Router) {
|
||||
r.Group(func(protected chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
protected.Use(authMiddleware.Runner)
|
||||
|
||||
protected.Post("/code", h.getAuthCode)
|
||||
})
|
||||
r.Get("/authorize", h.AuthorizeClient)
|
||||
r.Post("/token", h.tokenEndpoint)
|
||||
})
|
||||
}
|
345
internal/oauth/token.go
Normal file
345
internal/oauth/token.go
Normal file
@ -0,0 +1,345 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *repository.ApiService, nonce *string) (*types.SignedToken, *types.SignedToken, *types.SignedToken, error) {
|
||||
accessExpiresIn := 15 * time.Minute
|
||||
accessExpiresAt := time.Now().Add(accessExpiresIn)
|
||||
accessJTI := uuid.New()
|
||||
|
||||
accessClaims := types.ApiClaims{
|
||||
Permissions: []string{},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: apiService.ClientID,
|
||||
Audience: jwt.ClaimStrings{apiService.ClientID},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(accessExpiresAt),
|
||||
ID: accessJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
access, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var roles = []string{"user"}
|
||||
|
||||
if user.IsAdmin {
|
||||
roles = append(roles, "admin")
|
||||
}
|
||||
|
||||
idExpiresIn := 15 * time.Minute
|
||||
idExpiresAt := time.Now().Add(idExpiresIn)
|
||||
idJTI := uuid.New()
|
||||
|
||||
idClaims := types.IdTokenClaims{
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Name: user.FullName,
|
||||
Picture: user.ProfilePicture,
|
||||
Nonce: nonce,
|
||||
Roles: roles,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
Audience: jwt.ClaimStrings{apiService.ClientID},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(idExpiresAt),
|
||||
ID: idJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
idToken, err := util.SignJwtToken(idClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
refreshExpiresIn := 24 * time.Hour
|
||||
refreshExpiresAt := time.Now().Add(refreshExpiresIn)
|
||||
refreshJTI := uuid.New()
|
||||
|
||||
refreshClaims := types.ApiRefreshClaims{
|
||||
UserID: user.ID.String(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: apiService.ClientID,
|
||||
Audience: jwt.ClaimStrings{apiService.ClientID},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExpiresAt),
|
||||
ID: refreshJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
refresh, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return types.NewSignedToken(idToken, idExpiresAt, idJTI), types.NewSignedToken(access, accessExpiresAt, accessJTI), types.NewSignedToken(refresh, refreshExpiresAt, refreshJTI), nil
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("[OAUTH] New request to token endpoint")
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Basic ") {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode credentials
|
||||
payload, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid auth encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var clientId string
|
||||
var clientSecret string
|
||||
|
||||
parts := strings.SplitN(string(payload), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
clientId = parts[0]
|
||||
clientSecret = parts[1]
|
||||
|
||||
log.Printf("Some client is trying to exchange code with id: %s and secret: %s\n", clientId, clientSecret)
|
||||
|
||||
// Parse the form data
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
|
||||
log.Println("DEBUG: Verifying target oauth client before proceeding...")
|
||||
|
||||
if _, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{
|
||||
ClientID: clientId,
|
||||
RedirectURI: nil,
|
||||
State: "",
|
||||
Scopes: nil,
|
||||
}); err != nil {
|
||||
web.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
redirectUri := r.FormValue("redirect_uri")
|
||||
log.Printf("Redirect URI is %s\n", redirectUri)
|
||||
|
||||
code := r.FormValue("code")
|
||||
|
||||
fmt.Printf("Code received: %s\n", code)
|
||||
|
||||
codeSession, err := h.cache.GetAuthCode(r.Context(), code)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to find session under the code %s: %v\n", code, err)
|
||||
web.Error(w, "no session found under this auth code", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: Fetched code session: %#v\n", codeSession)
|
||||
|
||||
apiService, err := h.repo.GetApiServiceCID(r.Context(), codeSession.ClientID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Could not find API service with client %s: %v\n", codeSession.ClientID, err)
|
||||
web.Error(w, "service is not registered", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if codeSession.ClientID != clientId {
|
||||
web.Error(w, "invalid auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(codeSession.UserID))
|
||||
if err != nil {
|
||||
web.Error(w, "requested user not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
id, access, refresh, err := h.signApiTokens(&user, &apiService, &codeSession.Nonce)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to sign api tokens:", err)
|
||||
web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: Created api tokens: %v\n\n%v\n\n%v\n", id.ID.String(), access.ID.String(), refresh.ID.String())
|
||||
|
||||
userId, err := uuid.Parse(codeSession.UserID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse user '%s' uuid: %v\n", codeSession.UserID, err)
|
||||
web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ipAddr := util.GetClientIP(r)
|
||||
ua := r.UserAgent()
|
||||
|
||||
session, err := h.repo.CreateServiceSession(r.Context(), repository.CreateServiceSessionParams{
|
||||
ServiceID: apiService.ID,
|
||||
ClientID: apiService.ClientID,
|
||||
UserID: &userId,
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
LastActive: nil,
|
||||
IpAddress: &ipAddr,
|
||||
UserAgent: &ua,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to create new service session: %v\n", err)
|
||||
web.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("INFO: Service session created for '%s' client_id with '%s' id\n", apiService.ClientID, session.ID.String())
|
||||
|
||||
type Response struct {
|
||||
IdToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Email string `json:"email"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
// TODO: add scope (RFC 8693 $2)
|
||||
}
|
||||
|
||||
response := Response{
|
||||
IdToken: id.Token,
|
||||
TokenType: "Bearer",
|
||||
AccessToken: access.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
ExpiresIn: math.Ceil(access.ExpiresAt.Sub(time.Now()).Seconds()),
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
log.Printf("sending following response: %#v\n", response)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(response); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
case "refresh_token":
|
||||
refreshToken := r.FormValue("refresh_token")
|
||||
|
||||
var claims types.ApiRefreshClaims
|
||||
|
||||
token, err := util.VerifyToken(refreshToken, h.cfg.Jwt.PublicKey, &claims)
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
expire, err := claims.GetExpirationTime()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(expire.Time) {
|
||||
web.Error(w, "token is expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
refreshJTI, err := uuid.Parse(claims.ID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse refresh token JTI as uuid: %v\n", err)
|
||||
web.Error(w, "failed to refresh token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.repo.GetServiceSessionByRefreshJTI(r.Context(), &refreshJTI)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to find session by '%s' refresh jti: %v\n", refreshJTI.String(), err)
|
||||
web.Error(w, "session invalid", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !session.IsActive {
|
||||
log.Printf("INFO: Session with id '%s' is not active", session.ID.String())
|
||||
web.Error(w, "session ended", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(claims.UserID)
|
||||
if err != nil {
|
||||
web.Error(w, "invalid user credentials in refresh token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, err := h.repo.FindUserId(r.Context(), userID)
|
||||
|
||||
apiService, err := h.repo.GetApiServiceCID(r.Context(), claims.Subject)
|
||||
if err != nil {
|
||||
web.Error(w, "api service is not registered", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
id, access, refresh, err := h.signApiTokens(&user, &apiService, nil)
|
||||
|
||||
if err := h.repo.UpdateServiceSessionTokens(r.Context(), repository.UpdateServiceSessionTokensParams{
|
||||
ID: session.ID,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
}); err != nil {
|
||||
log.Printf("ERR: Failed to update service session with '%s' id: %v\n", session.ID.String(), err)
|
||||
web.Error(w, "failed to update session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
IdToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
response := Response{
|
||||
IdToken: id.Token,
|
||||
TokenType: "Bearer",
|
||||
AccessToken: access.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
ExpiresIn: math.Ceil(access.ExpiresAt.Sub(time.Now()).Seconds()),
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: refresh - sending following response: %#v\n", response)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(response); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
default:
|
||||
web.Error(w, "unsupported grant type", http.StatusBadRequest)
|
||||
}
|
||||
}
|
241
internal/repository/api_services.sql.go
Normal file
241
internal/repository/api_services.sql.go
Normal file
@ -0,0 +1,241 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: api_services.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const activateApiService = `-- name: ActivateApiService :exec
|
||||
UPDATE api_services
|
||||
SET is_active = true,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ActivateApiService(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, activateApiService, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const createApiService = `-- name: CreateApiService :one
|
||||
INSERT INTO api_services (
|
||||
client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_active
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8
|
||||
) RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url
|
||||
`
|
||||
|
||||
type CreateApiServiceParams struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateApiService(ctx context.Context, arg CreateApiServiceParams) (ApiService, error) {
|
||||
row := q.db.QueryRow(ctx, createApiService,
|
||||
arg.ClientID,
|
||||
arg.ClientSecret,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.RedirectUris,
|
||||
arg.Scopes,
|
||||
arg.GrantTypes,
|
||||
arg.IsActive,
|
||||
)
|
||||
var i ApiService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ClientID,
|
||||
&i.ClientSecret,
|
||||
&i.Name,
|
||||
&i.RedirectUris,
|
||||
&i.Scopes,
|
||||
&i.GrantTypes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deactivateApiService = `-- name: DeactivateApiService :exec
|
||||
UPDATE api_services
|
||||
SET is_active = false,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeactivateApiService(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, deactivateApiService, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getApiServiceCID = `-- name: GetApiServiceCID :one
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
|
||||
WHERE client_id = $1
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetApiServiceCID(ctx context.Context, clientID string) (ApiService, error) {
|
||||
row := q.db.QueryRow(ctx, getApiServiceCID, clientID)
|
||||
var i ApiService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ClientID,
|
||||
&i.ClientSecret,
|
||||
&i.Name,
|
||||
&i.RedirectUris,
|
||||
&i.Scopes,
|
||||
&i.GrantTypes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getApiServiceId = `-- name: GetApiServiceId :one
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetApiServiceId(ctx context.Context, id uuid.UUID) (ApiService, error) {
|
||||
row := q.db.QueryRow(ctx, getApiServiceId, id)
|
||||
var i ApiService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ClientID,
|
||||
&i.ClientSecret,
|
||||
&i.Name,
|
||||
&i.RedirectUris,
|
||||
&i.Scopes,
|
||||
&i.GrantTypes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listApiServices = `-- name: ListApiServices :many
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListApiServices(ctx context.Context) ([]ApiService, error) {
|
||||
rows, err := q.db.Query(ctx, listApiServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ApiService
|
||||
for rows.Next() {
|
||||
var i ApiService
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ClientID,
|
||||
&i.ClientSecret,
|
||||
&i.Name,
|
||||
&i.RedirectUris,
|
||||
&i.Scopes,
|
||||
&i.GrantTypes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateApiService = `-- name: UpdateApiService :one
|
||||
UPDATE api_services
|
||||
SET
|
||||
name = $2,
|
||||
description = $3,
|
||||
redirect_uris = $4,
|
||||
scopes = $5,
|
||||
grant_types = $6,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url
|
||||
`
|
||||
|
||||
type UpdateApiServiceParams struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateApiService(ctx context.Context, arg UpdateApiServiceParams) (ApiService, error) {
|
||||
row := q.db.QueryRow(ctx, updateApiService,
|
||||
arg.ClientID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.RedirectUris,
|
||||
arg.Scopes,
|
||||
arg.GrantTypes,
|
||||
)
|
||||
var i ApiService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ClientID,
|
||||
&i.ClientSecret,
|
||||
&i.Name,
|
||||
&i.RedirectUris,
|
||||
&i.Scopes,
|
||||
&i.GrantTypes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateClientSecret = `-- name: UpdateClientSecret :exec
|
||||
UPDATE api_services
|
||||
SET client_secret = $2,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
`
|
||||
|
||||
type UpdateClientSecretParams struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateClientSecret(ctx context.Context, arg UpdateClientSecretParams) error {
|
||||
_, err := q.db.Exec(ctx, updateClientSecret, arg.ClientID, arg.ClientSecret)
|
||||
return err
|
||||
}
|
@ -5,17 +5,73 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
LastLogin pgtype.Timestamptz `json:"last_login"`
|
||||
type ApiService struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Name string `json:"name"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Description *string `json:"description"`
|
||||
IconUrl *string `json:"icon_url"`
|
||||
}
|
||||
|
||||
type ServiceSession struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserID *uuid.UUID `json:"user_id"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
Scope *string `json:"scope"`
|
||||
Claims []byte `json:"claims"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt *time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
LastLogin *time.Time `json:"last_login"`
|
||||
PhoneNumber *string `json:"phone_number"`
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
AvatarVerified bool `json:"avatar_verified"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
type UserSession struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
SessionType string `json:"session_type"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
DeviceInfo []byte `json:"device_info"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
}
|
||||
|
419
internal/repository/service_sessions.sql.go
Normal file
419
internal/repository/service_sessions.sql.go
Normal file
@ -0,0 +1,419 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: service_sessions.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createServiceSession = `-- name: CreateServiceSession :one
|
||||
INSERT INTO service_sessions (
|
||||
service_id, client_id, user_id, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
is_active, scope, claims
|
||||
) VALUES (
|
||||
$1, $2, $3, NOW(), $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
TRUE, $10, $11
|
||||
)
|
||||
RETURNING id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims
|
||||
`
|
||||
|
||||
type CreateServiceSessionParams struct {
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserID *uuid.UUID `json:"user_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
Scope *string `json:"scope"`
|
||||
Claims []byte `json:"claims"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateServiceSession(ctx context.Context, arg CreateServiceSessionParams) (ServiceSession, error) {
|
||||
row := q.db.QueryRow(ctx, createServiceSession,
|
||||
arg.ServiceID,
|
||||
arg.ClientID,
|
||||
arg.UserID,
|
||||
arg.ExpiresAt,
|
||||
arg.LastActive,
|
||||
arg.IpAddress,
|
||||
arg.UserAgent,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.Scope,
|
||||
arg.Claims,
|
||||
)
|
||||
var i ServiceSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSessionByAccessJTI = `-- name: GetServiceSessionByAccessJTI :one
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceSessionByAccessJTI(ctx context.Context, accessTokenID *uuid.UUID) (ServiceSession, error) {
|
||||
row := q.db.QueryRow(ctx, getServiceSessionByAccessJTI, accessTokenID)
|
||||
var i ServiceSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSessionByRefreshJTI = `-- name: GetServiceSessionByRefreshJTI :one
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE refresh_token_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (ServiceSession, error) {
|
||||
row := q.db.QueryRow(ctx, getServiceSessionByRefreshJTI, refreshTokenID)
|
||||
var i ServiceSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSessions = `-- name: GetServiceSessions :many
|
||||
SELECT session.id, session.service_id, session.client_id, session.user_id, session.issued_at, session.expires_at, session.last_active, session.ip_address, session.user_agent, session.access_token_id, session.refresh_token_id, session.is_active, session.revoked_at, session.scope, session.claims, service.id, service.client_id, service.client_secret, service.name, service.redirect_uris, service.scopes, service.grant_types, service.created_at, service.updated_at, service.is_active, service.description, service.icon_url, u.id, u.email, u.full_name, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.last_login, u.phone_number, u.profile_picture, u.created_by, u.email_verified, u.avatar_verified, u.verified
|
||||
FROM service_sessions AS session
|
||||
JOIN api_services AS service ON service.id = session.service_id
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type GetServiceSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type GetServiceSessionsRow struct {
|
||||
ServiceSession ServiceSession `json:"service_session"`
|
||||
ApiService ApiService `json:"api_service"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetServiceSessions(ctx context.Context, arg GetServiceSessionsParams) ([]GetServiceSessionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getServiceSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetServiceSessionsRow
|
||||
for rows.Next() {
|
||||
var i GetServiceSessionsRow
|
||||
if err := rows.Scan(
|
||||
&i.ServiceSession.ID,
|
||||
&i.ServiceSession.ServiceID,
|
||||
&i.ServiceSession.ClientID,
|
||||
&i.ServiceSession.UserID,
|
||||
&i.ServiceSession.IssuedAt,
|
||||
&i.ServiceSession.ExpiresAt,
|
||||
&i.ServiceSession.LastActive,
|
||||
&i.ServiceSession.IpAddress,
|
||||
&i.ServiceSession.UserAgent,
|
||||
&i.ServiceSession.AccessTokenID,
|
||||
&i.ServiceSession.RefreshTokenID,
|
||||
&i.ServiceSession.IsActive,
|
||||
&i.ServiceSession.RevokedAt,
|
||||
&i.ServiceSession.Scope,
|
||||
&i.ServiceSession.Claims,
|
||||
&i.ApiService.ID,
|
||||
&i.ApiService.ClientID,
|
||||
&i.ApiService.ClientSecret,
|
||||
&i.ApiService.Name,
|
||||
&i.ApiService.RedirectUris,
|
||||
&i.ApiService.Scopes,
|
||||
&i.ApiService.GrantTypes,
|
||||
&i.ApiService.CreatedAt,
|
||||
&i.ApiService.UpdatedAt,
|
||||
&i.ApiService.IsActive,
|
||||
&i.ApiService.Description,
|
||||
&i.ApiService.IconUrl,
|
||||
&i.User.ID,
|
||||
&i.User.Email,
|
||||
&i.User.FullName,
|
||||
&i.User.PasswordHash,
|
||||
&i.User.IsAdmin,
|
||||
&i.User.CreatedAt,
|
||||
&i.User.UpdatedAt,
|
||||
&i.User.LastLogin,
|
||||
&i.User.PhoneNumber,
|
||||
&i.User.ProfilePicture,
|
||||
&i.User.CreatedBy,
|
||||
&i.User.EmailVerified,
|
||||
&i.User.AvatarVerified,
|
||||
&i.User.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getServiceSessionsCount = `-- name: GetServiceSessionsCount :one
|
||||
SELECT COUNT(*) FROM service_sessions
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceSessionsCount(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, getServiceSessionsCount)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const listActiveServiceSessionsByClient = `-- name: ListActiveServiceSessionsByClient :many
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE client_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListActiveServiceSessionsByClientParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListActiveServiceSessionsByClient(ctx context.Context, arg ListActiveServiceSessionsByClientParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveServiceSessionsByClient, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ServiceSession
|
||||
for rows.Next() {
|
||||
var i ServiceSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listActiveServiceSessionsByUser = `-- name: ListActiveServiceSessionsByUser :many
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListActiveServiceSessionsByUserParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListActiveServiceSessionsByUser(ctx context.Context, arg ListActiveServiceSessionsByUserParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveServiceSessionsByUser, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ServiceSession
|
||||
for rows.Next() {
|
||||
var i ServiceSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAllServiceSessions = `-- name: ListAllServiceSessions :many
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListAllServiceSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllServiceSessions(ctx context.Context, arg ListAllServiceSessionsParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listAllServiceSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ServiceSession
|
||||
for rows.Next() {
|
||||
var i ServiceSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const revokeServiceSession = `-- name: RevokeServiceSession :exec
|
||||
UPDATE service_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) RevokeServiceSession(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, revokeServiceSession, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateServiceSessionLastActive = `-- name: UpdateServiceSessionLastActive :exec
|
||||
UPDATE service_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateServiceSessionLastActive(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, updateServiceSessionLastActive, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateServiceSessionTokens = `-- name: UpdateServiceSessionTokens :exec
|
||||
UPDATE service_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
type UpdateServiceSessionTokensParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateServiceSessionTokens(ctx context.Context, arg UpdateServiceSessionTokensParams) error {
|
||||
_, err := q.db.Exec(ctx, updateServiceSessionTokens,
|
||||
arg.ID,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
334
internal/repository/user_sessions.sql.go
Normal file
334
internal/repository/user_sessions.sql.go
Normal file
@ -0,0 +1,334 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: user_sessions.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createUserSession = `-- name: CreateUserSession :one
|
||||
INSERT INTO user_sessions (
|
||||
user_id, session_type, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
device_info, is_active
|
||||
) VALUES (
|
||||
$1, $2, NOW(), $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
$9, TRUE
|
||||
)
|
||||
RETURNING id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at
|
||||
`
|
||||
|
||||
type CreateUserSessionParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
SessionType string `json:"session_type"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
DeviceInfo []byte `json:"device_info"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error) {
|
||||
row := q.db.QueryRow(ctx, createUserSession,
|
||||
arg.UserID,
|
||||
arg.SessionType,
|
||||
arg.ExpiresAt,
|
||||
arg.LastActive,
|
||||
arg.IpAddress,
|
||||
arg.UserAgent,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.DeviceInfo,
|
||||
)
|
||||
var i UserSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSessionByAccessJTI = `-- name: GetUserSessionByAccessJTI :one
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserSessionByAccessJTI(ctx context.Context, accessTokenID *uuid.UUID) (UserSession, error) {
|
||||
row := q.db.QueryRow(ctx, getUserSessionByAccessJTI, accessTokenID)
|
||||
var i UserSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSessionByRefreshJTI = `-- name: GetUserSessionByRefreshJTI :one
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
WHERE refresh_token_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (UserSession, error) {
|
||||
row := q.db.QueryRow(ctx, getUserSessionByRefreshJTI, refreshTokenID)
|
||||
var i UserSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSessions = `-- name: GetUserSessions :many
|
||||
SELECT session.id, session.user_id, session.session_type, session.issued_at, session.expires_at, session.last_active, session.ip_address, session.user_agent, session.access_token_id, session.refresh_token_id, session.device_info, session.is_active, session.revoked_at, u.id, u.email, u.full_name, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.last_login, u.phone_number, u.profile_picture, u.created_by, u.email_verified, u.avatar_verified, u.verified
|
||||
FROM user_sessions AS session
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type GetUserSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type GetUserSessionsRow struct {
|
||||
UserSession UserSession `json:"user_session"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserSessions(ctx context.Context, arg GetUserSessionsParams) ([]GetUserSessionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getUserSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserSessionsRow
|
||||
for rows.Next() {
|
||||
var i GetUserSessionsRow
|
||||
if err := rows.Scan(
|
||||
&i.UserSession.ID,
|
||||
&i.UserSession.UserID,
|
||||
&i.UserSession.SessionType,
|
||||
&i.UserSession.IssuedAt,
|
||||
&i.UserSession.ExpiresAt,
|
||||
&i.UserSession.LastActive,
|
||||
&i.UserSession.IpAddress,
|
||||
&i.UserSession.UserAgent,
|
||||
&i.UserSession.AccessTokenID,
|
||||
&i.UserSession.RefreshTokenID,
|
||||
&i.UserSession.DeviceInfo,
|
||||
&i.UserSession.IsActive,
|
||||
&i.UserSession.RevokedAt,
|
||||
&i.User.ID,
|
||||
&i.User.Email,
|
||||
&i.User.FullName,
|
||||
&i.User.PasswordHash,
|
||||
&i.User.IsAdmin,
|
||||
&i.User.CreatedAt,
|
||||
&i.User.UpdatedAt,
|
||||
&i.User.LastLogin,
|
||||
&i.User.PhoneNumber,
|
||||
&i.User.ProfilePicture,
|
||||
&i.User.CreatedBy,
|
||||
&i.User.EmailVerified,
|
||||
&i.User.AvatarVerified,
|
||||
&i.User.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getUserSessionsCount = `-- name: GetUserSessionsCount :one
|
||||
SELECT COUNT(*) FROM user_sessions
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserSessionsCount(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, getUserSessionsCount)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const listActiveUserSessions = `-- name: ListActiveUserSessions :many
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListActiveUserSessions(ctx context.Context, userID uuid.UUID) ([]UserSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveUserSessions, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []UserSession
|
||||
for rows.Next() {
|
||||
var i UserSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAllSessions = `-- name: ListAllSessions :many
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListAllSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllSessions(ctx context.Context, arg ListAllSessionsParams) ([]UserSession, error) {
|
||||
rows, err := q.db.Query(ctx, listAllSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []UserSession
|
||||
for rows.Next() {
|
||||
var i UserSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const revokeUserSession = `-- name: RevokeUserSession :exec
|
||||
UPDATE user_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) RevokeUserSession(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, revokeUserSession, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSessionLastActive = `-- name: UpdateSessionLastActive :exec
|
||||
UPDATE user_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateSessionLastActive(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, updateSessionLastActive, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSessionTokens = `-- name: UpdateSessionTokens :exec
|
||||
UPDATE user_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
type UpdateSessionTokensParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSessionTokens(ctx context.Context, arg UpdateSessionTokensParams) error {
|
||||
_, err := q.db.Exec(ctx, updateSessionTokens,
|
||||
arg.ID,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
@ -11,8 +11,47 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const findAdminUsers = `-- name: FindAdminUsers :many
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE created_by = $1
|
||||
`
|
||||
|
||||
func (q *Queries) FindAdminUsers(ctx context.Context, createdBy *uuid.UUID) ([]User, error) {
|
||||
rows, err := q.db.Query(ctx, findAdminUsers, createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []User
|
||||
for rows.Next() {
|
||||
var i User
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.FullName,
|
||||
&i.PasswordHash,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LastLogin,
|
||||
&i.PhoneNumber,
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const findAllUsers = `-- name: FindAllUsers :many
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login FROM users
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users
|
||||
`
|
||||
|
||||
func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
|
||||
@ -33,6 +72,12 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LastLogin,
|
||||
&i.PhoneNumber,
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -44,20 +89,73 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const findUserEmail = `-- name: FindUserEmail :one
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE email = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error) {
|
||||
row := q.db.QueryRow(ctx, findUserEmail, email)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.FullName,
|
||||
&i.PasswordHash,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LastLogin,
|
||||
&i.PhoneNumber,
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const findUserId = `-- name: FindUserId :one
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE id = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
row := q.db.QueryRow(ctx, findUserId, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.FullName,
|
||||
&i.PasswordHash,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LastLogin,
|
||||
&i.PhoneNumber,
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertUser = `-- name: InsertUser :one
|
||||
INSERT INTO users (
|
||||
email, full_name, password_hash, is_admin
|
||||
email, full_name, password_hash, is_admin, created_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type InsertUserParams struct {
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UUID, error) {
|
||||
@ -66,8 +164,69 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UU
|
||||
arg.FullName,
|
||||
arg.PasswordHash,
|
||||
arg.IsAdmin,
|
||||
arg.CreatedBy,
|
||||
)
|
||||
var id uuid.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const updateLastLogin = `-- name: UpdateLastLogin :exec
|
||||
UPDATE users
|
||||
SET last_login = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, updateLastLogin, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateProfilePicture = `-- name: UpdateProfilePicture :exec
|
||||
UPDATE users
|
||||
SET profile_picture = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
type UpdateProfilePictureParams struct {
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePictureParams) error {
|
||||
_, err := q.db.Exec(ctx, updateProfilePicture, arg.ProfilePicture, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const userVerifyAvatar = `-- name: UserVerifyAvatar :exec
|
||||
UPDATE users
|
||||
SET avatar_verified = true
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UserVerifyAvatar(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, userVerifyAvatar, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const userVerifyComplete = `-- name: UserVerifyComplete :exec
|
||||
UPDATE users
|
||||
SET verified = true
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UserVerifyComplete(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, userVerifyComplete, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const userVerifyEmail = `-- name: UserVerifyEmail :exec
|
||||
UPDATE users
|
||||
SET email_verified = true
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UserVerifyEmail(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, userVerifyEmail, id)
|
||||
return err
|
||||
}
|
||||
|
43
internal/storage/mod.go
Normal file
43
internal/storage/mod.go
Normal file
@ -0,0 +1,43 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type FileStorage struct {
|
||||
client *minio.Client
|
||||
}
|
||||
|
||||
func New(cfg *config.AppConfig) *FileStorage {
|
||||
client, err := minio.New(cfg.Minio.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.Minio.AccessKey, cfg.Minio.SecretKey, ""),
|
||||
Secure: false,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to create minio client:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &FileStorage{
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FileStorage) PutObject(ctx context.Context, bucketName string, objectName string, reader io.Reader, size int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) {
|
||||
return fs.client.PutObject(ctx, bucketName, objectName, reader, size, opts)
|
||||
}
|
||||
|
||||
func (fs *FileStorage) GetObject(ctx context.Context, bucketName string, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) {
|
||||
return fs.client.GetObject(ctx, bucketName, objectName, opts)
|
||||
}
|
||||
|
||||
func (fs *FileStorage) EndpointURL() *url.URL {
|
||||
return fs.client.EndpointURL()
|
||||
}
|
38
internal/types/apiservices.go
Normal file
38
internal/types/apiservices.go
Normal file
@ -0,0 +1,38 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ApiServiceDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
IconUrl *string `json:"icon_url"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO {
|
||||
return ApiServiceDTO{
|
||||
ID: service.ID,
|
||||
ClientID: service.ClientID,
|
||||
Name: service.Name,
|
||||
Description: service.Description,
|
||||
IconUrl: service.IconUrl,
|
||||
RedirectUris: service.RedirectUris,
|
||||
Scopes: service.Scopes,
|
||||
GrantTypes: service.GrantTypes,
|
||||
CreatedAt: service.CreatedAt,
|
||||
UpdatedAt: service.UpdatedAt,
|
||||
IsActive: service.IsActive,
|
||||
}
|
||||
}
|
33
internal/types/claims.go
Normal file
33
internal/types/claims.go
Normal file
@ -0,0 +1,33 @@
|
||||
package types
|
||||
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
type UserClaims struct {
|
||||
UserEmail string `json:"user_email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type IdTokenClaims struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture *string `json:"picture"`
|
||||
Nonce *string `json:"nonce"`
|
||||
Roles []string `json:"roles"`
|
||||
// TODO: add given_name, family_name, locale...
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type ApiClaims struct {
|
||||
// FIXME: correct permissions
|
||||
Permissions []string `json:"permissions"`
|
||||
jwt.RegisteredClaims
|
||||
// Subject = ClientID
|
||||
}
|
||||
|
||||
type ApiRefreshClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
// Subject = ClientID
|
||||
}
|
12
internal/types/device.go
Normal file
12
internal/types/device.go
Normal file
@ -0,0 +1,12 @@
|
||||
package types
|
||||
|
||||
type DeviceInfo struct {
|
||||
DeviceType string `json:"device_type"`
|
||||
OS string `json:"os"`
|
||||
OSVersion string `json:"os_version"`
|
||||
Browser string `json:"browser"`
|
||||
BrowserVersion string `json:"browser_version"`
|
||||
DeviceName string `json:"device_name"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Location string `json:"location"`
|
||||
}
|
6
internal/types/middleware.go
Normal file
6
internal/types/middleware.go
Normal file
@ -0,0 +1,6 @@
|
||||
package types
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserIdKey contextKey = "userID"
|
||||
const JTIKey contextKey = "jti"
|
29
internal/types/session.go
Normal file
29
internal/types/session.go
Normal file
@ -0,0 +1,29 @@
|
||||
package types
|
||||
|
||||
import "gitea.local/admin/hspguard/internal/repository"
|
||||
|
||||
type ServiceSessionDTO struct {
|
||||
User UserDTO `json:"user"`
|
||||
ApiService ApiServiceDTO `json:"api_service"`
|
||||
repository.ServiceSession
|
||||
}
|
||||
|
||||
func NewServiceSessionDTO(row *repository.GetServiceSessionsRow) *ServiceSessionDTO {
|
||||
return &ServiceSessionDTO{
|
||||
User: NewUserDTO(&row.User),
|
||||
ApiService: NewApiServiceDTO(row.ApiService),
|
||||
ServiceSession: row.ServiceSession,
|
||||
}
|
||||
}
|
||||
|
||||
type UserSessionDTO struct {
|
||||
User UserDTO `json:"user"`
|
||||
repository.UserSession
|
||||
}
|
||||
|
||||
func NewUserSessionDTO(row *repository.GetUserSessionsRow) *UserSessionDTO {
|
||||
return &UserSessionDTO{
|
||||
User: NewUserDTO(&row.User),
|
||||
UserSession: row.UserSession,
|
||||
}
|
||||
}
|
21
internal/types/token.go
Normal file
21
internal/types/token.go
Normal file
@ -0,0 +1,21 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SignedToken struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
ID uuid.UUID
|
||||
}
|
||||
|
||||
func NewSignedToken(token string, expiresAt time.Time, jti uuid.UUID) *SignedToken {
|
||||
return &SignedToken{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
ID: jti,
|
||||
}
|
||||
}
|
40
internal/types/user.go
Normal file
40
internal/types/user.go
Normal file
@ -0,0 +1,40 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt *time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
LastLogin *time.Time `json:"last_login"`
|
||||
PhoneNumber *string `json:"phone_number"`
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
AvatarVerified bool `json:"avatar_verified"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
func NewUserDTO(row *repository.User) UserDTO {
|
||||
return UserDTO{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
FullName: row.FullName,
|
||||
IsAdmin: row.IsAdmin,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
LastLogin: row.LastLogin,
|
||||
PhoneNumber: row.PhoneNumber,
|
||||
ProfilePicture: row.ProfilePicture,
|
||||
EmailVerified: row.EmailVerified,
|
||||
AvatarVerified: row.AvatarVerified,
|
||||
Verified: row.Verified,
|
||||
}
|
||||
}
|
40
internal/user/admin.go
Normal file
40
internal/user/admin.go
Normal file
@ -0,0 +1,40 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func EnsureAdminUser(ctx context.Context, cfg *config.AppConfig, repo *repository.Queries) {
|
||||
_, err := repo.FindUserEmail(ctx, cfg.Admin.Email)
|
||||
if err != nil {
|
||||
if cfg.Admin.Password == "" {
|
||||
log.Fatalln("ERR: ADMIN_PASSWORD env variable is required")
|
||||
}
|
||||
|
||||
if _, err := createAdmin(ctx, cfg.Admin.Name, cfg.Admin.Email, cfg.Admin.Password, repo); err != nil {
|
||||
log.Fatalln("ERR: Failed to create admin account:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createAdmin(ctx context.Context, name, email, password string, repo *repository.Queries) (uuid.UUID, error) {
|
||||
hash, err := util.HashPassword(password)
|
||||
if err != nil {
|
||||
var id uuid.UUID
|
||||
return id, fmt.Errorf("failed to hash the admin password")
|
||||
}
|
||||
|
||||
return repo.InsertUser(ctx, repository.InsertUserParams{
|
||||
FullName: name,
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
IsAdmin: true,
|
||||
})
|
||||
}
|
205
internal/user/routes.go
Normal file
205
internal/user/routes.go
Normal file
@ -0,0 +1,205 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/storage"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
repo *repository.Queries
|
||||
minio *storage.FileStorage
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage, cfg *config.AppConfig) *UserHandler {
|
||||
return &UserHandler{
|
||||
repo,
|
||||
minio,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UserHandler) RegisterRoutes(api chi.Router) {
|
||||
api.Group(func(protected chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
protected.Use(authMiddleware.Runner)
|
||||
|
||||
protected.Put("/avatar", h.uploadAvatar)
|
||||
})
|
||||
|
||||
api.Post("/register", h.register)
|
||||
api.Get("/avatar/{avatar}", h.getAvatar)
|
||||
}
|
||||
|
||||
type RegisterParams struct {
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
PhoneNumber string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *UserHandler) register(w http.ResponseWriter, r *http.Request) {
|
||||
var params RegisterParams
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(¶ms); err != nil {
|
||||
web.Error(w, "failed to parse request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if params.Email == "" || params.FullName == "" || params.Password == "" {
|
||||
web.Error(w, "missing required fields", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.repo.FindUserEmail(context.Background(), params.Email)
|
||||
if err == nil {
|
||||
web.Error(w, "user with provided email already exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := util.HashPassword(params.Password)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to create user account", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.repo.InsertUser(context.Background(), repository.InsertUserParams{
|
||||
FullName: params.FullName,
|
||||
Email: params.Email,
|
||||
PasswordHash: hash,
|
||||
IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
web.Error(w, "failed to create new user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
type Response struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Id: id.String(),
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UserHandler) getAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
avatarObject := chi.URLParam(r, "avatar")
|
||||
|
||||
object, err := h.minio.GetObject(r.Context(), "guard-storage", avatarObject, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
web.Error(w, "avatar not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer object.Close()
|
||||
|
||||
stat, err := object.Stat()
|
||||
if err != nil {
|
||||
log.Printf("ERR: failed to get object stats: %v\n", err)
|
||||
web.Error(w, "failed to get avatar", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", stat.ContentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.Copy(w, object)
|
||||
}
|
||||
|
||||
func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
err = r.ParseMultipartForm(10 << 20)
|
||||
if err != nil {
|
||||
web.Error(w, "invalid form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
web.Error(w, "missing image file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" {
|
||||
web.Error(w, "unsupported image format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
objectName := fmt.Sprintf("profile_%s_%d%s", userId, time.Now().UnixNano(), ext)
|
||||
|
||||
uploadInfo, err := h.minio.PutObject(r.Context(), "guard-storage", objectName, file, header.Size, minio.PutObjectOptions{
|
||||
ContentType: header.Header.Get("Content-Type"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to put object:", err)
|
||||
web.Error(w, "failed to upload image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
imgURI := fmt.Sprintf("%s/api/v1/avatar/%s", h.cfg.Uri, uploadInfo.Key)
|
||||
|
||||
if err := h.repo.UpdateProfilePicture(r.Context(), repository.UpdateProfilePictureParams{
|
||||
ProfilePicture: &imgURI,
|
||||
ID: user.ID,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to update profile picture", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.AvatarVerified {
|
||||
if err := h.repo.UserVerifyAvatar(r.Context(), user.ID); err != nil {
|
||||
log.Println("ERR: Failed to update avatar_verified:", err)
|
||||
web.Error(w, "failed to verify avatar", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{URL: fmt.Sprintf("%s/avatar/%s", h.cfg.Uri, uploadInfo.Key)}); err != nil {
|
||||
web.Error(w, "failed to write response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
9
internal/util/client.go
Normal file
9
internal/util/client.go
Normal file
@ -0,0 +1,9 @@
|
||||
package util
|
||||
|
||||
func GenerateClientID() (string, error) {
|
||||
return generateRandomStringURLSafe(16)
|
||||
}
|
||||
|
||||
func GenerateClientSecret() (string, error) {
|
||||
return generateRandomStringURLSafe(32)
|
||||
}
|
17
internal/util/generate.go
Normal file
17
internal/util/generate.go
Normal file
@ -0,0 +1,17 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// generateRandomStringURLSafe generates a base64 URL-safe random string of n bytes.
|
||||
func generateRandomStringURLSafe(n int) (string, error) {
|
||||
bytes := make([]byte, n)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
15
internal/util/hash.go
Normal file
15
internal/util/hash.go
Normal file
@ -0,0 +1,15 @@
|
||||
package util
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// HashPassword generates a bcrypt hash for the given password.
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// VerifyPassword verifies if the given password matches the stored hash.
|
||||
func VerifyPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
81
internal/util/jwt.go
Normal file
81
internal/util/jwt.go
Normal file
@ -0,0 +1,81 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func ParseBase64PrivateKey(b64 string) (*rsa.PrivateKey, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
|
||||
}
|
||||
|
||||
key, err := x509.ParsePKCS8PrivateKey(decoded)
|
||||
return key.(*rsa.PrivateKey), err
|
||||
}
|
||||
|
||||
func ParseBase64PublicKey(b64 string) (*rsa.PublicKey, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
|
||||
}
|
||||
|
||||
pubInterface, err := x509.ParsePKIXPublicKey(decoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key: %v", err)
|
||||
}
|
||||
|
||||
pubKey, ok := pubInterface.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an RSA public key")
|
||||
}
|
||||
|
||||
return pubKey, nil
|
||||
}
|
||||
|
||||
func SignJwtToken(claims jwt.Claims, key string) (string, error) {
|
||||
privateKey, err := ParseBase64PrivateKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
|
||||
token.Header["kid"] = "my-rsa-key-1"
|
||||
|
||||
s, err := token.SignedString(privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func VerifyToken(token string, key string, claims jwt.Claims) (*jwt.Token, error) {
|
||||
publicKey, err := ParseBase64PublicKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.Valid {
|
||||
return nil, fmt.Errorf("token is not valid")
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
46
internal/util/location.go
Normal file
46
internal/util/location.go
Normal file
@ -0,0 +1,46 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LocationResult struct {
|
||||
Country string `json:"country"`
|
||||
Region string `json:"regionName"`
|
||||
City string `json:"city"`
|
||||
}
|
||||
|
||||
func GetLocation(ip string) (LocationResult, error) {
|
||||
var loc LocationResult
|
||||
// Example using ipinfo.io free API
|
||||
resp, err := http.Get("http://ip-api.com/json/" + ip + "?fields=25")
|
||||
if err != nil {
|
||||
return loc, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
json.NewDecoder(resp.Body).Decode(&loc)
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
func GetClientIP(r *http.Request) string {
|
||||
// This header will be set by ngrok to the original client IP
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
log.Printf("DEBUG: Getting IP from X-Forwarded-For: %s\n", xff)
|
||||
// X-Forwarded-For: client, proxy1, proxy2, ...
|
||||
ips := strings.Split(xff, ",")
|
||||
if len(ips) > 0 {
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
}
|
||||
// Fallback to RemoteAddr (not the real client IP, but just in case)
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
log.Printf("DEBUG: Falling to request remote addr: %s (%s)\n", host, r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
17
internal/util/request.go
Normal file
17
internal/util/request.go
Normal file
@ -0,0 +1,17 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
)
|
||||
|
||||
func GetRequestUserId(ctx context.Context) (string, bool) {
|
||||
userId, ok := ctx.Value(types.UserIdKey).(string)
|
||||
return userId, ok
|
||||
}
|
||||
|
||||
func GetRequestJTI(ctx context.Context) (string, bool) {
|
||||
jti, ok := ctx.Value(types.JTIKey).(string)
|
||||
return jti, ok
|
||||
}
|
39
internal/util/session.go
Normal file
39
internal/util/session.go
Normal file
@ -0,0 +1,39 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"github.com/avct/uasurfer"
|
||||
)
|
||||
|
||||
func BuildDeviceInfo(userAgent string, remoteAddr string) []byte {
|
||||
var deviceInfo types.DeviceInfo
|
||||
|
||||
parsed := uasurfer.Parse(userAgent)
|
||||
|
||||
deviceInfo.Browser = parsed.Browser.Name.StringTrimPrefix()
|
||||
deviceInfo.BrowserVersion = fmt.Sprintf("%d.%d.%d", parsed.Browser.Version.Major, parsed.Browser.Version.Minor, parsed.Browser.Version.Patch)
|
||||
deviceInfo.DeviceName = fmt.Sprintf("%s %s", parsed.OS.Platform.StringTrimPrefix(), parsed.OS.Name.StringTrimPrefix())
|
||||
deviceInfo.DeviceType = parsed.DeviceType.StringTrimPrefix()
|
||||
deviceInfo.OS = parsed.OS.Platform.StringTrimPrefix()
|
||||
deviceInfo.OSVersion = fmt.Sprintf("%d.%d.%d", parsed.OS.Version.Major, parsed.OS.Version.Minor, parsed.OS.Version.Patch)
|
||||
deviceInfo.UserAgent = userAgent
|
||||
|
||||
if location, err := GetLocation(remoteAddr); err != nil {
|
||||
log.Printf("WARN: Failed to get location from ip (%s): %v\n", remoteAddr, err)
|
||||
} else {
|
||||
log.Printf("DEBUG: Response from IP fetcher: %#v\n", location)
|
||||
deviceInfo.Location = fmt.Sprintf("%s, %s, %s", location.Country, location.Region, location.City)
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(deviceInfo)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to serialize device info: %v\n", err)
|
||||
serialized = []byte{'{', '}'}
|
||||
}
|
||||
|
||||
return serialized
|
||||
}
|
17
internal/web/error.go
Normal file
17
internal/web/error.go
Normal file
@ -0,0 +1,17 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Error(w http.ResponseWriter, err string, code int) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": err,
|
||||
"status": code,
|
||||
})
|
||||
}
|
||||
|
27
internal/web/templates.go
Normal file
27
internal/web/templates.go
Normal file
@ -0,0 +1,27 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func RenderTemplate(w http.ResponseWriter, page string, data map[string]any) {
|
||||
base, err := template.ParseGlob(filepath.Join("templates", "layout", "*.html"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := base.ParseFiles(filepath.Join("templates", "pages", fmt.Sprintf("%s.html", page)))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
11
migrations/00002_add_phone_number.sql
Normal file
11
migrations/00002_add_phone_number.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD phone_number TEXT DEFAULT '';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
DROP COLUMN phone_number;
|
||||
-- +goose StatementEnd
|
11
migrations/00003_add_profile_image.sql
Normal file
11
migrations/00003_add_profile_image.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD profile_picture TEXT;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
DROP COLUMN profile_picture;
|
||||
-- +goose StatementEnd
|
27
migrations/00004_create_services_table.sql
Normal file
27
migrations/00004_create_services_table.sql
Normal file
@ -0,0 +1,27 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE api_services (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Unique identifier
|
||||
|
||||
-- OIDC-required fields
|
||||
client_id TEXT UNIQUE NOT NULL,
|
||||
client_secret TEXT NOT NULL, -- Store as hashed value
|
||||
|
||||
-- Metadata
|
||||
name TEXT NOT NULL,
|
||||
|
||||
redirect_uris TEXT[] DEFAULT '{}',
|
||||
scopes TEXT[] DEFAULT '{openid}',
|
||||
grant_types TEXT[] DEFAULT '{authorization_code}',
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
is_active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE api_services;
|
||||
-- +goose StatementEnd
|
11
migrations/00005_add_description_to_api_service.sql
Normal file
11
migrations/00005_add_description_to_api_service.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE api_services ADD description TEXT DEFAULT '';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE api_services
|
||||
DROP COLUMN description;
|
||||
|
||||
-- +goose StatementEnd
|
12
migrations/00006_add_user_creator.sql
Normal file
12
migrations/00006_add_user_creator.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD COLUMN created_by UUID REFERENCES users (id) ON DELETE SET NULL;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
DROP COLUMN created_by;
|
||||
|
||||
-- +goose StatementEnd
|
12
migrations/00007_add_user_verification.sql
Normal file
12
migrations/00007_add_user_verification.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
DROP COLUMN email_verified;
|
||||
|
||||
-- +goose StatementEnd
|
12
migrations/00008_add_verification_levels.sql
Normal file
12
migrations/00008_add_verification_levels.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD COLUMN avatar_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
DROP COLUMN avatar_verified;
|
||||
|
||||
-- +goose StatementEnd
|
12
migrations/00009_add_complete_verify.sql
Normal file
12
migrations/00009_add_complete_verify.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD COLUMN verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
DROP COLUMN verified;
|
||||
|
||||
-- +goose StatementEnd
|
12
migrations/00010_add_api_service_icon_url.sql
Normal file
12
migrations/00010_add_api_service_icon_url.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE api_services
|
||||
ADD COLUMN icon_url TEXT DEFAULT NULL;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE api_services
|
||||
DROP COLUMN icon_url;
|
||||
|
||||
-- +goose StatementEnd
|
34
migrations/00011_add_user_sessions_table.sql
Normal file
34
migrations/00011_add_user_sessions_table.sql
Normal file
@ -0,0 +1,34 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE user_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
user_id UUID REFERENCES users (id) NOT NULL,
|
||||
session_type VARCHAR(32) NOT NULL DEFAULT 'user', -- e.g. 'user', 'admin'
|
||||
issued_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE NOT NULL DEFAULT NOW (),
|
||||
expires_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
last_active TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
ip_address VARCHAR(45), -- supports IPv4/IPv6
|
||||
user_agent TEXT,
|
||||
access_token_id UUID,
|
||||
refresh_token_id UUID,
|
||||
device_info JSONB, -- optional: structured info (browser, OS, etc.)
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
revoked_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions (user_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS user_sessions;
|
||||
|
||||
-- +goose StatementEnd
|
38
migrations/00012_add_service_sessions.sql
Normal file
38
migrations/00012_add_service_sessions.sql
Normal file
@ -0,0 +1,38 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE service_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
service_id UUID REFERENCES api_services (id) NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
user_id UUID REFERENCES users (id), -- user on behalf of whom the service is acting, nullable for direct use with client creds
|
||||
issued_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE NOT NULL DEFAULT NOW (),
|
||||
expires_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
last_active TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
access_token_id UUID,
|
||||
refresh_token_id UUID,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
revoked_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
scope TEXT, -- what scopes/permissions this session was issued for
|
||||
claims JSONB -- snapshot of claims at session start, optional
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_sessions_client_id ON service_sessions (client_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_sessions_user_id ON service_sessions (user_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS service_sessions;
|
||||
|
||||
-- +goose StatementEnd
|
51
queries/api_services.sql
Normal file
51
queries/api_services.sql
Normal file
@ -0,0 +1,51 @@
|
||||
-- name: CreateApiService :one
|
||||
INSERT INTO api_services (
|
||||
client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_active
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8
|
||||
) RETURNING *;
|
||||
|
||||
-- name: GetApiServiceCID :one
|
||||
SELECT * FROM api_services
|
||||
WHERE client_id = $1
|
||||
AND is_active = true
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetApiServiceId :one
|
||||
SELECT * FROM api_services
|
||||
WHERE id = $1
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListApiServices :many
|
||||
SELECT * FROM api_services
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateApiService :one
|
||||
UPDATE api_services
|
||||
SET
|
||||
name = $2,
|
||||
description = $3,
|
||||
redirect_uris = $4,
|
||||
scopes = $5,
|
||||
grant_types = $6,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeactivateApiService :exec
|
||||
UPDATE api_services
|
||||
SET is_active = false,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1;
|
||||
|
||||
-- name: ActivateApiService :exec
|
||||
UPDATE api_services
|
||||
SET is_active = true,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1;
|
||||
|
||||
-- name: UpdateClientSecret :exec
|
||||
UPDATE api_services
|
||||
SET client_secret = $2,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1;
|
69
queries/service_sessions.sql
Normal file
69
queries/service_sessions.sql
Normal file
@ -0,0 +1,69 @@
|
||||
-- name: CreateServiceSession :one
|
||||
INSERT INTO service_sessions (
|
||||
service_id, client_id, user_id, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
is_active, scope, claims
|
||||
) VALUES (
|
||||
$1, $2, $3, NOW(), $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
TRUE, $10, $11
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListActiveServiceSessionsByClient :many
|
||||
SELECT * FROM service_sessions
|
||||
WHERE client_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: ListActiveServiceSessionsByUser :many
|
||||
SELECT * FROM service_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessionByAccessJTI :one
|
||||
SELECT * FROM service_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: GetServiceSessionByRefreshJTI :one
|
||||
SELECT * FROM service_sessions
|
||||
WHERE refresh_token_id = $1;
|
||||
|
||||
-- name: RevokeServiceSession :exec
|
||||
UPDATE service_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateServiceSessionLastActive :exec
|
||||
UPDATE service_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateServiceSessionTokens :exec
|
||||
UPDATE service_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: ListAllServiceSessions :many
|
||||
SELECT * FROM service_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessions :many
|
||||
SELECT sqlc.embed(session), sqlc.embed(service), sqlc.embed(u)
|
||||
FROM service_sessions AS session
|
||||
JOIN api_services AS service ON service.id = session.service_id
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessionsCount :one
|
||||
SELECT COUNT(*) FROM service_sessions;
|
60
queries/user_sessions.sql
Normal file
60
queries/user_sessions.sql
Normal file
@ -0,0 +1,60 @@
|
||||
-- name: CreateUserSession :one
|
||||
INSERT INTO user_sessions (
|
||||
user_id, session_type, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
device_info, is_active
|
||||
) VALUES (
|
||||
$1, $2, NOW(), $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
$9, TRUE
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListActiveUserSessions :many
|
||||
SELECT * FROM user_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC;
|
||||
|
||||
-- name: GetUserSessionByAccessJTI :one
|
||||
SELECT * FROM user_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: GetUserSessionByRefreshJTI :one
|
||||
SELECT * FROM user_sessions
|
||||
WHERE refresh_token_id = $1;
|
||||
|
||||
-- name: RevokeUserSession :exec
|
||||
UPDATE user_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateSessionLastActive :exec
|
||||
UPDATE user_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateSessionTokens :exec
|
||||
UPDATE user_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: ListAllSessions :many
|
||||
SELECT * FROM user_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetUserSessions :many
|
||||
SELECT sqlc.embed(session), sqlc.embed(u)
|
||||
FROM user_sessions AS session
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetUserSessionsCount :one
|
||||
SELECT COUNT(*) FROM user_sessions;
|
@ -1,11 +1,44 @@
|
||||
-- name: FindAllUsers :many
|
||||
SELECT * FROM users;
|
||||
|
||||
-- name: FindAdminUsers :many
|
||||
SELECT * FROM users WHERE created_by = $1;
|
||||
|
||||
-- name: InsertUser :one
|
||||
INSERT INTO users (
|
||||
email, full_name, password_hash, is_admin
|
||||
email, full_name, password_hash, is_admin, created_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING id;
|
||||
|
||||
-- name: FindUserEmail :one
|
||||
SELECT * FROM users WHERE email = $1 LIMIT 1;
|
||||
|
||||
-- name: FindUserId :one
|
||||
SELECT * FROM users WHERE id = $1 LIMIT 1;
|
||||
|
||||
-- name: UpdateProfilePicture :exec
|
||||
UPDATE users
|
||||
SET profile_picture = $1
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: UserVerifyEmail :exec
|
||||
UPDATE users
|
||||
SET email_verified = true
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UserVerifyAvatar :exec
|
||||
UPDATE users
|
||||
SET avatar_verified = true
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UserVerifyComplete :exec
|
||||
UPDATE users
|
||||
SET verified = true
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateLastLogin :exec
|
||||
UPDATE users
|
||||
SET last_login = NOW()
|
||||
WHERE id = $1;
|
||||
|
4
redis.conf
Normal file
4
redis.conf
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
# Enable ACL
|
||||
user default off
|
||||
user guard on >guard allcommands allkeys
|
17
scripts/generate-jwt-keys.ps1
Normal file
17
scripts/generate-jwt-keys.ps1
Normal file
@ -0,0 +1,17 @@
|
||||
# Generate 2048-bit RSA private key (suppress output)
|
||||
openssl genpkey -algorithm RSA -out rsa-private.pem -pkeyopt rsa_keygen_bits:2048 *> $null
|
||||
|
||||
# Extract the public key from the private key (suppress output)
|
||||
openssl rsa -in rsa-private.pem -pubout -out rsa-public.pem *> $null
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# Base64 encode private key (DER format, for JWT_PRIVATE_KEY)
|
||||
Write-Host -NoNewline 'JWT_PRIVATE_KEY="'
|
||||
openssl pkcs8 -topk8 -nocrypt -in rsa-private.pem -outform DER 2>$null | openssl base64 -A
|
||||
Write-Host '"'
|
||||
|
||||
# Base64 encode public key (DER format, for JWT_PUBLIC_KEY)
|
||||
Write-Host -NoNewline 'JWT_PUBLIC_KEY="'
|
||||
openssl rsa -in rsa-private.pem -pubout -outform DER 2>$null | openssl base64 -A
|
||||
Write-Host '"'
|
19
scripts/generate-jwt-keys.sh
Executable file
19
scripts/generate-jwt-keys.sh
Executable file
@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Generate 2048-bit RSA private key (suppress all output)
|
||||
openssl genpkey -algorithm RSA -out rsa-private.pem -pkeyopt rsa_keygen_bits:2048 >/dev/null 2>&1
|
||||
|
||||
# Extract the public key from the private key (suppress all output)
|
||||
openssl rsa -in rsa-private.pem -pubout -out rsa-public.pem >/dev/null 2>&1
|
||||
|
||||
echo ""
|
||||
|
||||
# Base64 encode private key (for JWT_PRIVATE_KEY)
|
||||
echo -n 'JWT_PRIVATE_KEY="'
|
||||
openssl pkcs8 -topk8 -nocrypt -in rsa-private.pem -outform DER 2>/dev/null | base64 -w 0
|
||||
echo '"'
|
||||
|
||||
# Base64 encode public key (for JWT_PUBLIC_KEY)
|
||||
echo -n 'JWT_PUBLIC_KEY="'
|
||||
openssl rsa -in rsa-private.pem -pubout -outform DER 2>/dev/null | base64 -w 0
|
||||
echo '"'
|
111
sqlc.yaml
111
sqlc.yaml
@ -1,4 +1,3 @@
|
||||
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
@ -14,8 +13,114 @@ sql:
|
||||
- db_type: "uuid"
|
||||
go_type:
|
||||
import: "github.com/google/uuid"
|
||||
type: "UUID"
|
||||
- db_type: "timestamptz"
|
||||
type: UUID
|
||||
- db_type: "uuid"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "github.com/google/uuid"
|
||||
type: UUID
|
||||
pointer: true
|
||||
# ───── bool ──────────────────────────────────────────
|
||||
- db_type: "pg_catalog.bool" # or just "bool"
|
||||
go_type: { type: "bool" }
|
||||
- db_type: "bool" # or just "bool"
|
||||
go_type: { type: "bool" }
|
||||
|
||||
- db_type: "pg_catalog.bool"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "bool"
|
||||
pointer: true # ⇒ *bool for NULLable columns
|
||||
|
||||
- db_type: "bool"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "bool"
|
||||
pointer: true # ⇒ *bool for NULLable columns
|
||||
|
||||
# ───── text ──────────────────────────────────────────
|
||||
- db_type: "pg_catalog.text"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "text"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "pg_catalog.text"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
- db_type: "text"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
- db_type: "pg_catalog.varchar"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "varchar"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "pg_catalog.varchar"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
- db_type: "varchar"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
# ───── timestamp (WITHOUT TZ) ────────────────────────
|
||||
- db_type: "pg_catalog.timestamp" # or "timestamp"
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
|
||||
- db_type: "timestamp" # or "timestamp"
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
|
||||
- db_type: "pg_catalog.timestamp"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
pointer: true
|
||||
|
||||
- db_type: "timestamp"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
pointer: true
|
||||
|
||||
# ───── timestamptz (WITH TZ) ─────────────────────────
|
||||
- db_type: "pg_catalog.timestamptz" # or "timestamptz"
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
|
||||
- db_type: "timestamptz" # or "timestamptz"
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
|
||||
- db_type: "pg_catalog.timestamptz"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
pointer: true
|
||||
|
||||
- db_type: "timestamptz"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
pointer: true
|
||||
|
25
templates/layout/base.html
Normal file
25
templates/layout/base.html
Normal file
@ -0,0 +1,25 @@
|
||||
{{ define "base" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css" >
|
||||
<link rel="icon" type="image/png" href="/static/icon.png">
|
||||
</head>
|
||||
<body>
|
||||
<!-- <header> -->
|
||||
<!-- <h1>{{ .Title }}</h1> -->
|
||||
<!-- </header> -->
|
||||
<div class="container">
|
||||
<main class="modal-box">
|
||||
{{ block "content" . }}{{ end }}
|
||||
</main>
|
||||
{{ template "footer" . }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
5
templates/layout/footer.html
Normal file
5
templates/layout/footer.html
Normal file
@ -0,0 +1,5 @@
|
||||
{{ define "footer" }}
|
||||
<link rel="stylesheet" href="/static/css/footer.css">
|
||||
<footer class="footer"><p>© 2025 HSP Guard</p></footer>
|
||||
{{ end }}
|
||||
|
45
templates/pages/login.html
Normal file
45
templates/pages/login.html
Normal file
@ -0,0 +1,45 @@
|
||||
{{ define "content" }}
|
||||
<link rel="stylesheet" href="/static/css/login.css">
|
||||
<div class="icon-wrapper">
|
||||
<img src="/static/icon.png" alt="icon" class="icon">
|
||||
</div>
|
||||
<h1 class="modal-title">
|
||||
Welcome to Home Guard
|
||||
</h1>
|
||||
<h3 class="modal-description">
|
||||
Enter your credentials to access home services and tools.
|
||||
</h3>
|
||||
<form method="POST" action="/api/v1/login" class="form">
|
||||
<div class="input-group">
|
||||
<div class="input-icon">
|
||||
@
|
||||
</div>
|
||||
<input class="input-field" type="email" name="email" placeholder="Email">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-icon">
|
||||
<img src="/static/icons/padlock.png" alt="user">
|
||||
</div>
|
||||
<input class="input-field" type="password" name="password" placeholder="Password">
|
||||
</div>
|
||||
|
||||
<div class="validation_box" id="validationBox">
|
||||
<p class="validation_box__msg" id="validationMsg">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="success_box" id="successBox">
|
||||
<p class="success_box__msg" id="successMsg">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="button primary login-btn" type="submit">
|
||||
Login
|
||||
</button>
|
||||
|
||||
<div class="login_link">
|
||||
Don't have an account? <a href="/register">Register</a>
|
||||
</div>
|
||||
</form>
|
||||
<script src="/static/js/login.js"></script>
|
||||
{{ end }}
|
70
templates/pages/register.html
Normal file
70
templates/pages/register.html
Normal file
@ -0,0 +1,70 @@
|
||||
{{ define "content" }}
|
||||
<link rel="stylesheet" href="/static/css/register.css">
|
||||
<div class="icon-wrapper">
|
||||
<img src="/static/icon.png" alt="icon" class="icon">
|
||||
</div>
|
||||
<h1 class="modal-title">
|
||||
Welcome to Home Guard
|
||||
</h1>
|
||||
<h3 class="modal-description">
|
||||
Create an account to access home services and tools.
|
||||
</h3>
|
||||
<form method="POST" action="/api/v1/register" class="form">
|
||||
<div class="input-group">
|
||||
<div class="input-icon">
|
||||
<img src="/static/icons/user.png" alt="user">
|
||||
</div>
|
||||
<input class="input-field" type="text" name="full_name" placeholder="Full Name*">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-icon">
|
||||
@
|
||||
</div>
|
||||
<input class="input-field" type="email" name="email" placeholder="Email*">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-icon">
|
||||
<img src="/static/icons/telephone.png" alt="user">
|
||||
</div>
|
||||
<input class="input-field" type="tel" name="phone" placeholder="Phone Number">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-icon">
|
||||
<img src="/static/icons/padlock.png" alt="user">
|
||||
</div>
|
||||
<input class="input-field" type="password" name="password" placeholder="Password*">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-icon">
|
||||
<img src="/static/icons/padlock.png" alt="user">
|
||||
</div>
|
||||
<input class="input-field" type="password" name="repeat_password" placeholder="Repeat Password*">
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" name="terms_and_conditions">
|
||||
<div>
|
||||
<p>By checking this checkbox I submit, that read and accepted terms and conditions of this service and home lab.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="validation_box" id="validationBox">
|
||||
<p class="validation_box__msg" id="validationMsg">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="success_box" id="successBox">
|
||||
<p class="success_box__msg" id="successMsg">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="button primary register-btn" type="submit">
|
||||
Register
|
||||
</button>
|
||||
|
||||
<div class="login_link">
|
||||
Already have an account? <a href="/login">Login</a>
|
||||
</div>
|
||||
</form>
|
||||
<script src="/static/js/register.js"></script>
|
||||
{{ end }}
|
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
5
web/.prettierignore
Normal file
5
web/.prettierignore
Normal file
@ -0,0 +1,5 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
node_modules
|
||||
public
|
1
web/.prettierrc
Normal file
1
web/.prettierrc
Normal file
@ -0,0 +1 @@
|
||||
{}
|
54
web/README.md
Normal file
54
web/README.md
Normal file
@ -0,0 +1,54 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from "eslint-plugin-react-x";
|
||||
import reactDom from "eslint-plugin-react-dom";
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
"react-x": reactX,
|
||||
"react-dom": reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs["recommended-typescript"].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
});
|
||||
```
|
28
web/eslint.config.js
Normal file
28
web/eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
6
web/globals.d.ts
vendored
Normal file
6
web/globals.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
interface Window {
|
||||
guard: {
|
||||
refreshing: boolean;
|
||||
refreshQueue: ((token: string | null) => void)[];
|
||||
};
|
||||
}
|
14
web/index.html
Normal file
14
web/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Home Guard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="portal-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
4798
web/package-lock.json
generated
Normal file
4798
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
web/package.json
Normal file
46
web/package.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:watch": "tsc -b && vite build --watch",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"axios": "^1.9.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.511.0",
|
||||
"moment": "^2.30.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-jwt": "^1.3.0",
|
||||
"react-router": "^7.6.1",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/node": "^22.15.19",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"path": "^0.12.7",
|
||||
"prettier": "3.5.3",
|
||||
"sass": "^1.89.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
BIN
web/public/dark-overlay.jpg
Normal file
BIN
web/public/dark-overlay.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 59 KiB |
BIN
web/public/favicon.png
Normal file
BIN
web/public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.5 KiB |
BIN
web/public/icon.png
Normal file
BIN
web/public/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.5 KiB |
BIN
web/public/overlay.jpg
Normal file
BIN
web/public/overlay.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 279 KiB |
158
web/src/App.tsx
Normal file
158
web/src/App.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import { type FC } from "react";
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";
|
||||
|
||||
import IndexPage from "./pages/Index";
|
||||
import LoginPage from "./pages/Login";
|
||||
import RegisterPage from "./pages/Register";
|
||||
import AuthorizePage from "./pages/Authorize";
|
||||
import AuthenticatePage from "./pages/Authenticate";
|
||||
import AuthLayout from "./layout/AuthLayout";
|
||||
import DashboardLayout from "./layout/DashboardLayout";
|
||||
import PersonalInfoPage from "./pages/PersonalInfo";
|
||||
import ApiServicesPage from "./pages/Admin/ApiServices";
|
||||
import AdminLayout from "./layout/AdminLayout";
|
||||
import ApiServiceCreatePage from "./pages/Admin/ApiServices/Create";
|
||||
import ViewApiServicePage from "./pages/Admin/ApiServices/View";
|
||||
import NotAllowedPage from "./pages/NotAllowed";
|
||||
import NotFoundPage from "./pages/NotFound";
|
||||
import ApiServiceEditPage from "./pages/Admin/ApiServices/Update";
|
||||
import AdminUsersPage from "./pages/Admin/Users";
|
||||
import AdminViewUserPage from "./pages/Admin/Users/View";
|
||||
import AdminCreateUserPage from "./pages/Admin/Users/Create";
|
||||
import VerificationLayout from "./layout/VerificationLayout";
|
||||
import VerifyStartPage from "./pages/Verify";
|
||||
import VerifyEmailPage from "./pages/Verify/Email";
|
||||
import VerifyEmailOtpPage from "./pages/Verify/Email/OTP";
|
||||
import VerifyAvatarPage from "./pages/Verify/Avatar";
|
||||
import VerifyReviewPage from "./pages/Verify/Review";
|
||||
import AdminUserSessionsPage from "./pages/Admin/UserSessions";
|
||||
import AdminServiceSessionsPage from "./pages/Admin/ServiceSessions";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
element: <DashboardLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <IndexPage />,
|
||||
},
|
||||
{
|
||||
path: "personal-info",
|
||||
element: <PersonalInfoPage />,
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
element: <AdminLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/admin/api-services" />,
|
||||
},
|
||||
{
|
||||
path: "api-services",
|
||||
children: [
|
||||
{ index: true, element: <ApiServicesPage /> },
|
||||
{ path: "create", element: <ApiServiceCreatePage /> },
|
||||
{
|
||||
path: "view/:serviceId",
|
||||
element: <ViewApiServicePage />,
|
||||
},
|
||||
{
|
||||
path: "edit/:serviceId",
|
||||
element: <ApiServiceEditPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
children: [
|
||||
{ index: true, element: <AdminUsersPage /> },
|
||||
{ path: "create", element: <AdminCreateUserPage /> },
|
||||
{
|
||||
path: "view/:userId",
|
||||
element: <AdminViewUserPage />,
|
||||
},
|
||||
// {
|
||||
// path: "edit/:serviceId",
|
||||
// element: <ApiServiceEditPage />,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "user-sessions",
|
||||
children: [{ index: true, element: <AdminUserSessionsPage /> }],
|
||||
},
|
||||
{
|
||||
path: "service-sessions",
|
||||
children: [
|
||||
{ index: true, element: <AdminServiceSessionsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/verify",
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{
|
||||
path: "/verify",
|
||||
element: <VerificationLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <VerifyStartPage />,
|
||||
},
|
||||
{
|
||||
path: "email",
|
||||
element: <VerifyEmailPage />,
|
||||
},
|
||||
{
|
||||
path: "email/otp",
|
||||
element: <VerifyEmailOtpPage />,
|
||||
},
|
||||
{
|
||||
path: "avatar",
|
||||
element: <VerifyAvatarPage />,
|
||||
},
|
||||
{
|
||||
path: "review",
|
||||
element: <VerifyReviewPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/auth",
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{ index: true, element: <AuthorizePage /> },
|
||||
{ path: "login", element: <LoginPage /> },
|
||||
{ path: "register", element: <RegisterPage /> },
|
||||
{ path: "authenticate", element: <AuthenticatePage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/not-allowed",
|
||||
element: <NotAllowedPage />,
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <NotFoundPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
const App: FC = () => {
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
export default App;
|
104
web/src/api/admin/apiServices.ts
Normal file
104
web/src/api/admin/apiServices.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import type { ApiService, ApiServiceCredentials } from "@/types";
|
||||
import { axios, handleApiError } from "..";
|
||||
|
||||
export interface FetchApiServicesResponse {
|
||||
items: ApiService[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const getApiServices = async (): Promise<FetchApiServicesResponse> => {
|
||||
const response = await axios.get<FetchApiServicesResponse>(
|
||||
"/api/v1/admin/api-services",
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface CreateApiServiceRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
redirect_uris: string[];
|
||||
scopes: string[];
|
||||
grant_types: string[];
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface CreateApiServiceResponse {
|
||||
service: ApiService;
|
||||
credentials: ApiServiceCredentials;
|
||||
}
|
||||
|
||||
export const postApiService = async (
|
||||
req: CreateApiServiceRequest,
|
||||
): Promise<CreateApiServiceResponse> => {
|
||||
const response = await axios.post<CreateApiServiceResponse>(
|
||||
"/api/v1/admin/api-services",
|
||||
req,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getApiService = async (id: string): Promise<ApiService> => {
|
||||
const response = await axios.get<ApiService>(
|
||||
`/api/v1/admin/api-services/${id}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getApiServiceCID = async (
|
||||
clientId: string,
|
||||
): Promise<ApiService> => {
|
||||
const response = await axios.get<ApiService>(
|
||||
`/api/v1/api-services/client/${clientId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const patchToggleApiService = async (id: string): Promise<void> => {
|
||||
const response = await axios.patch(`/api/v1/admin/api-services/toggle/${id}`);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface UpdateApiServiceRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
redirect_uris: string[];
|
||||
scopes: string[];
|
||||
grant_types: string[];
|
||||
}
|
||||
|
||||
export type UpdateApiServiceResponse = ApiService;
|
||||
|
||||
export const putApiService = async (
|
||||
serviceId: string,
|
||||
req: UpdateApiServiceRequest,
|
||||
): Promise<UpdateApiServiceResponse> => {
|
||||
const response = await axios.put<UpdateApiServiceResponse>(
|
||||
`/api/v1/admin/api-services/${serviceId}`,
|
||||
req,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
78
web/src/api/admin/sessions.ts
Normal file
78
web/src/api/admin/sessions.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { ServiceSession, UserSession } from "@/types";
|
||||
import { axios, handleApiError } from "..";
|
||||
|
||||
export interface FetchUserSessionsRequest {
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FetchUserSessionsResponse {
|
||||
items: UserSession[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export const adminGetUserSessionsApi = async (
|
||||
req: FetchUserSessionsRequest,
|
||||
): Promise<FetchUserSessionsResponse> => {
|
||||
const response = await axios.get<FetchUserSessionsResponse>(
|
||||
"/api/v1/admin/user-sessions",
|
||||
{
|
||||
params: req,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const adminRevokeUserSessionApi = async (
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
const response = await axios.patch<FetchServiceSessionsResponse>(
|
||||
`/api/v1/admin/user-sessions/revoke/${sessionId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
};
|
||||
|
||||
export interface FetchServiceSessionsRequest {
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FetchServiceSessionsResponse {
|
||||
items: ServiceSession[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export const adminGetServiceSessionsApi = async (
|
||||
req: FetchServiceSessionsRequest,
|
||||
): Promise<FetchServiceSessionsResponse> => {
|
||||
const response = await axios.get<FetchServiceSessionsResponse>(
|
||||
"/api/v1/admin/service-sessions",
|
||||
{
|
||||
params: req,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const adminRevokeServiceSessionApi = async (
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
const response = await axios.patch<FetchServiceSessionsResponse>(
|
||||
`/api/v1/admin/service-sessions/revoke/${sessionId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user