Compare commits

...

471 Commits

Author SHA1 Message Date
d35e5813b5 feat: beta version of role management for single user 2025-07-20 17:59:54 +02:00
533e6ea6af fix: no avatar handling 2025-06-30 00:08:25 +02:00
0c24ed9382 feat: check role assignment 2025-06-30 00:08:14 +02:00
f5c61bb6a0 feat: show roles 2025-06-29 23:20:59 +02:00
eb05f830fe feat: roles & group state 2025-06-29 23:20:50 +02:00
d86a9de388 feat: assign system roles 2025-06-29 23:19:05 +02:00
d80caac81b feat: roles API + type def 2025-06-25 11:56:06 +02:00
5d49a661ed feat: add roles & groups page 2025-06-25 11:55:52 +02:00
635a2d6058 feat: roles & groups page 2025-06-25 11:55:41 +02:00
1eb96e906d feat: create system roles 2025-06-25 11:55:27 +02:00
58974d9789 feat: add scope to the role 2025-06-25 11:55:04 +02:00
329fac415f feat: get all roles endpoint 2025-06-25 11:54:45 +02:00
a83ec61fae fix: avoid null value 2025-06-25 11:54:19 +02:00
d5e86acacf fix: import of user permissions API 2025-06-24 19:05:21 +02:00
221ef192bc feat: permissions store 2025-06-24 19:05:07 +02:00
18664dbd8b feat: remove image borders for service 2025-06-24 19:04:59 +02:00
d097735965 fix: remove unnecessary test fethcing 2025-06-24 19:04:48 +02:00
992776b8a6 feat: new nav item for app permissions 2025-06-24 19:04:26 +02:00
3f260b9029 feat: add API for fetching ALL permissions 2025-06-24 19:04:15 +02:00
65f40d0897 feat: app permissions admin page 2025-06-24 19:04:01 +02:00
6d482c784f feat: admin fetch all permissions 2025-06-24 19:03:45 +02:00
dc07868d15 feat: call permissions ensure 2025-06-24 19:01:26 +02:00
09a2f05ee5 feat: fetch permissions + grouped fetching 2025-06-24 19:01:16 +02:00
5cec1cf561 feat: ensure system permissions 2025-06-24 19:00:36 +02:00
3281764eff feat: display user's raw permissions 2025-06-24 14:37:25 +02:00
868337134d feat: get permissions call 2025-06-24 12:59:10 +02:00
9372673bf1 test: get user permissions endpoint 2025-06-24 12:58:29 +02:00
0eea81b42f feat: update repo with group, roles and permissions 2025-06-24 12:58:14 +02:00
7468303e41 feat: make name + scope combi unique 2025-06-24 12:18:54 +02:00
8745e7d8bc feat: group role permission 2025-06-17 12:21:02 +02:00
bdc42beb27 Merge pull request 'sessions' (#2) from sessions into main
Reviewed-on: #2
2025-06-16 19:03:01 +02:00
72083fa0a4 hot+fix: round expires in number 2025-06-16 19:01:28 +02:00
b3ad13e55d feat: ignore .env.remote 2025-06-15 22:16:17 +02:00
0db54e0268 feat: update service session on refresh 2025-06-15 21:13:33 +02:00
b3ef96a0ce feat: update service session's tokens 2025-06-15 21:13:23 +02:00
a773f1f8b4 feat: signed token type 2025-06-15 21:05:09 +02:00
1a71f50914 fix: 'boolean' instead of 'bool' 2025-06-15 21:05:03 +02:00
d17e154e42 feat: logical columns for service sessions table 2025-06-15 21:04:50 +02:00
bad26775eb fix: don't redirect due to credentials modal 2025-06-15 21:04:40 +02:00
c3fd6637a5 fix: numeration 2025-06-15 21:04:22 +02:00
20173ea140 fix: numeration 2025-06-15 21:04:07 +02:00
41d439beab feat: create service session 2025-06-15 21:02:38 +02:00
b36b6e18ca feat: use signed token from types 2025-06-15 21:02:22 +02:00
1765485027 feat: decrease inner spacing 2025-06-15 19:41:05 +02:00
c5fb5e674a feat: revoke service session 2025-06-15 19:40:54 +02:00
03bf655051 feat: service sessions feature 2025-06-15 19:36:02 +02:00
f8589fce5d feat: refetch sessions 2025-06-15 19:29:03 +02:00
ac62158de9 feat: pagination + session revoking UI 2025-06-15 19:27:02 +02:00
44e1a18e9a feat: user sessions state 2025-06-15 19:26:52 +02:00
e0f2c3219f feat: message populating from responswe 2025-06-15 19:26:43 +02:00
d2d52dc041 feat: pagination + revoke session API 2025-06-15 19:26:27 +02:00
d7d142152c feat: repo update 2025-06-15 19:26:18 +02:00
5c321311cd feat: pagination support + fix: able to get inactive session 2025-06-15 19:26:01 +02:00
ffc8a5f44d feat: session verification 2025-06-15 19:25:21 +02:00
dbff94e7b3 feat: pass repo access to middleware 2025-06-15 19:25:13 +02:00
0b1ef77689 feat: user/service sessions type 2025-06-15 18:10:47 +02:00
b0005c6702 feat: add new nav item for user sessions 2025-06-15 18:10:31 +02:00
c2abf1a5ba feat: add admin sessions page 2025-06-15 18:10:21 +02:00
ac50929e6e feat: ui pagination component draft 2025-06-15 18:10:11 +02:00
32785398ca feat: user sessions list page 2025-06-15 18:10:00 +02:00
cc497b6016 feat: sessions API 2025-06-15 18:09:48 +02:00
97ffcbdaa4 feat: sessions endpoints 2025-06-15 18:09:26 +02:00
d09bf8ff02 feat: install moment 2025-06-15 18:09:08 +02:00
c0814093e5 feat: get user sessions joined with user data 2025-06-15 18:08:58 +02:00
d48519741d feat: add user-sessions routes 2025-06-15 18:08:44 +02:00
213991126d feat: move api services dto into separate types file 2025-06-15 18:08:34 +02:00
c7e88606e3 feat: return status of request 2025-06-13 21:46:06 +02:00
a0d506fb76 feat: navigate to list page after successful create 2025-06-13 21:45:53 +02:00
0ec7743fca feat: delimiter handling/support 2025-06-13 21:45:39 +02:00
a8a0fa55b7 feat: delimiter def 2025-06-13 21:45:32 +02:00
7321448ce7 fix: reload accounts 2025-06-11 21:06:36 +02:00
6d5e0fc9a9 feat: signout API 2025-06-11 21:00:22 +02:00
ef05d66787 feat: register signout route 2025-06-11 20:52:30 +02:00
b3296c45ad feat: get request JTI helper 2025-06-11 20:52:22 +02:00
7fd163f957 feat: signout endpoitn 2025-06-11 20:51:39 +02:00
0f0d50a684 feat: set token jti in request 2025-06-11 20:39:34 +02:00
68074e02bc feat: jti request key 2025-06-11 20:39:20 +02:00
8d38a86f86 feat: get client ip util 2025-06-11 20:35:38 +02:00
e0c095c24d feat: create/update session when refreshing 2025-06-11 20:34:56 +02:00
4c318b15cd feat: refactor login 2025-06-11 20:33:49 +02:00
5ea6bc4251 feat: build device info util 2025-06-11 20:33:34 +02:00
1cbe908489 fix: use printf 2025-06-11 18:49:39 +02:00
53ee156e67 feat: get location util 2025-06-11 18:48:54 +02:00
07a936acc7 feat: device info type 2025-06-11 18:48:48 +02:00
f892f0da24 feat: update session + ps type overriding 2025-06-11 18:48:41 +02:00
38955ee4e6 fix: use the token 2025-06-11 18:48:08 +02:00
7fa7e87e88 feat: token sign with meta data 2025-06-11 18:47:59 +02:00
f085f2e271 feat: create user session 2025-06-11 18:47:45 +02:00
08add259a4 feat: install uasurfer 2025-06-11 18:47:34 +02:00
5b6142dfa6 feat: user/service sessions repo 2025-06-10 19:46:37 +02:00
dc41521a99 fix+feat: use verify oauth client helper in token as well 2025-06-09 15:55:36 +02:00
299e7eddc4 feat: verify api service in code generation 2025-06-09 15:39:56 +02:00
b4699e987c feat: service and user session queries 2025-06-08 22:59:33 +02:00
be9d4f2a1b feat: user and service sessions 2025-06-08 22:59:24 +02:00
db99236501 Merge branch 'main' of git.adalspace.com:admin/hspguard 2025-06-08 17:00:36 +02:00
e33fb04c99 feat: specify supported grant tyoes 2025-06-08 16:59:49 +02:00
c3d4208e12 Update README.md 2025-06-08 16:43:13 +02:00
3f945fa329 Update README.md 2025-06-08 16:38:26 +02:00
93a5cd7c70 feat: center icon 2025-06-07 22:08:23 +02:00
951de989af fix: remove unnecessary double message 2025-06-07 22:07:38 +02:00
c5cf253a15 feat: log error when failing to put object 2025-06-07 22:07:28 +02:00
d70032e36d feat: copy redis.conf 2025-06-07 22:07:16 +02:00
445ac50537 feat: list of permissions 2025-06-07 19:50:51 +02:00
c13e564b01 feat: icon_url field in api service 2025-06-07 19:47:31 +02:00
5d3a77133d feat: fetch api service state 2025-06-07 19:47:22 +02:00
44592ebc08 feat: show api service's information 2025-06-07 19:47:12 +02:00
1b941cb0c3 fix: correct uri for fetching api service 2025-06-07 19:47:02 +02:00
1cb520c2b6 feat: expose api services fetching by client id 2025-06-07 19:46:34 +02:00
b3fdd3bc18 feat: expose icon url 2025-06-07 19:46:25 +02:00
9110db2f08 feat: api service fetch by client id api 2025-06-07 19:23:42 +02:00
5972735102 fix: separate pathname for getting api service by client id 2025-06-07 19:23:34 +02:00
14c69349cc feat: get api service by client handler and endpoint 2025-06-07 19:22:44 +02:00
3ceeab04e1 feat: add icon url to api service 2025-06-07 19:21:14 +02:00
b7a67c208f feat: pass client id by code generation 2025-06-07 19:17:48 +02:00
cee885a84d feat: remove unused access to user profile 2025-06-07 19:17:39 +02:00
4b496ea9bd feat: split claims into 2 2025-06-07 19:17:29 +02:00
570ae6ac8c feat: generate 3 tokens for api service 2025-06-07 19:17:15 +02:00
f0d3a61e7b feat: accept access to cache 2025-06-07 19:16:57 +02:00
b09567620f feat: generate auth code and save 2025-06-07 19:16:45 +02:00
2209846525 feat: check for redirect uris allowed 2025-06-07 19:16:28 +02:00
108ed61961 feat: save & get auth code cache 2025-06-07 19:16:14 +02:00
b73bfd590b feat: allow custom claims for verify 2025-06-07 19:15:59 +02:00
19d56159ba feat: pass access to cache 2025-06-07 19:15:00 +02:00
b9ccf6adac feat: require client id for code API 2025-06-07 18:46:46 +02:00
9a0870dbbc fix: let user see verify welcome message 2025-06-07 11:41:52 +02:00
70429f69a2 feat: reset verify states after completion 2025-06-07 11:17:10 +02:00
ad635008eb fix: scrolling + background + height on devices 2025-06-07 02:37:03 +02:00
eacc8fdd89 fix: no need for h-full 2025-06-07 02:21:17 +02:00
d309fb3f57 fix: better scrolling on mobile 2025-06-07 02:21:06 +02:00
f4fd993679 feat: admin default page is api-services 2025-06-07 02:20:41 +02:00
016879b53f feat: upload avatar API 2025-06-07 02:10:55 +02:00
70bba15cda feat: redirect + upload avatar store 2025-06-07 02:10:46 +02:00
57daf175ab fix: fallback to empty array 2025-06-07 02:10:35 +02:00
0817a65272 feat: final verify trigger 2025-06-07 02:10:24 +02:00
13f9da1a67 feat: handle avatar uploading on verify 2025-06-07 02:10:11 +02:00
83535acf1c feat: no users found text in table 2025-06-07 02:09:59 +02:00
441ce2daca feat: redirection after verification 2025-06-07 02:09:44 +02:00
f9848d2110 feat: set redirect in state 2025-06-07 02:09:35 +02:00
7f0511b0d4 feat: finish verification API 2025-06-07 02:09:20 +02:00
a27f2ad593 feat: verify user's avatar + full url for profile picture 2025-06-07 02:09:10 +02:00
715a984241 feat: register finish verify route 2025-06-07 02:08:52 +02:00
66e1756ade feat: finish verification handler 2025-06-07 02:08:43 +02:00
849403a137 feat: confirm OTP integration 2025-06-07 01:34:48 +02:00
8d15c9b8b2 feat: request OTP query 2025-06-07 01:34:38 +02:00
87af1834cf feat: email verify state 2025-06-07 01:34:28 +02:00
357583f54d feat: navigate to current stage of verification 2025-06-07 01:34:13 +02:00
aa6de76ded feat: redirect to verify pages 2025-06-07 01:33:50 +02:00
ab3c2d1eb0 feat: stepper improvements 2025-06-07 01:33:40 +02:00
644cf2a358 feat: email verify APIs 2025-06-07 01:33:21 +02:00
5b1ed9925d feat: verify routes 2025-06-07 01:33:14 +02:00
4071a50a37 feat: split auth routes in files 2025-06-07 01:32:56 +02:00
dd7c51efd8 feat: update user verifications 2025-06-07 01:32:37 +02:00
8902f4d187 feat: redis configuration & client 2025-06-07 01:32:22 +02:00
6666b20464 feat: go-redis pkg 2025-06-07 01:31:55 +02:00
c5f288ba1e feat: redis configuration 2025-06-07 01:31:47 +02:00
cc49ab1655 feat: verify state 2025-06-07 00:14:09 +02:00
06c60b3491 feat: verification layout 2025-06-07 00:14:03 +02:00
b584a7b07f feat: UI stepper component 2025-06-07 00:13:54 +02:00
410e420a46 feat: use dark background overlay 2025-06-07 00:13:45 +02:00
eeb0f6eac1 feat: user profile verification fields 2025-06-07 00:13:29 +02:00
fb622f918a feat: redirect to verify when required 2025-06-07 00:13:17 +02:00
a50bad417f feat: mask email util 2025-06-07 00:12:56 +02:00
c395729446 feat: register verification pages 2025-06-07 00:12:50 +02:00
eaa92d2fe4 feat: verification pages 2025-06-07 00:12:41 +02:00
a9e382d713 feat: new dark overlay img 2025-06-07 00:12:22 +02:00
974244025e feat: remove unused @emotion/react 2025-06-07 00:12:04 +02:00
ae41076673 feat: move UserDTO logic into single file 2025-06-07 00:11:46 +02:00
cc7f7f40c4 feat: add verification levels 2025-06-07 00:11:02 +02:00
e2ae03f2a6 feat: use avatar directly as URI 2025-06-06 12:37:34 +02:00
9319564dea feat: image full URI in avatar 2025-06-06 12:12:03 +02:00
83e3e5a2e9 feat: new env variable for server URI 2025-06-06 12:04:25 +02:00
2b40e4e922 feat: specify user creator + list only users related to admin 2025-06-06 11:58:47 +02:00
ed33d03fda feat: find users related to specific admin repo 2025-06-06 11:58:20 +02:00
34c152a459 fix: correct uri for user creation page 2025-06-05 20:50:44 +02:00
ad09e98bba feat: create user page 2025-06-05 20:50:27 +02:00
d3fd5cba16 feat: create user state 2025-06-05 20:49:52 +02:00
64dbb4368c feat: create user API 2025-06-05 20:49:43 +02:00
cb3a6ddc58 feat: add support for new fields in user table 2025-06-05 20:49:35 +02:00
e774f415d8 feat: craete user route 2025-06-05 20:49:20 +02:00
d5a22895e7 feat: user creator and verified columns 2025-06-05 20:49:05 +02:00
9983c51e3a fix: use fetch for single item 2025-06-04 21:12:30 +02:00
6a1fc193f4 fix: double scrollbar on desktop 2025-06-04 20:07:58 +02:00
118877f727 feat: user update last login 2025-06-04 20:07:48 +02:00
7b8fe6baf2 fix: set content-type to json 2025-06-04 20:07:39 +02:00
e85b23b3e8 feat: admin view user page 2025-06-04 19:33:41 +02:00
6164b77bee fix: avatar explicit color for icon 2025-06-04 19:33:28 +02:00
8b5a5744ab feat: get single user state 2025-06-04 19:33:12 +02:00
d9e9c5ab38 feat: get single user API 2025-06-04 19:33:07 +02:00
0dcef81b59 feat: get single user endpoint 2025-06-04 19:32:56 +02:00
426b70a1de feat: AdminUsersPage implementation 2025-06-04 19:17:20 +02:00
912973cdb5 feat: admin users state 2025-06-04 19:17:08 +02:00
e4ff799f05 feat: background enhancements 2025-06-04 19:16:48 +02:00
320715f5aa feat: move api services related pages to own folder 2025-06-04 19:11:51 +02:00
a3b04b6243 fix: correct spelling for is_admin 2025-06-04 19:11:28 +02:00
f610d7480f feat: register user related pages 2025-06-04 19:11:10 +02:00
11ac92a026 feat: dynamic user based roles 2025-06-04 19:11:01 +02:00
98ae3e06e9 feat: longer live time for token + correct user based role 2025-06-04 19:10:32 +02:00
a1146ce371 feat: separate file for api services store 2025-06-04 12:49:57 +02:00
a67ec7e78c fix: rename useAdmin to useApiServices 2025-06-04 12:46:48 +02:00
9895392b50 feat: fetch users API 2025-06-04 12:46:36 +02:00
c6998f33e1 feat: move user dto outside 2025-06-04 12:46:24 +02:00
81659181e4 feat: add get users route 2025-06-04 12:33:58 +02:00
849b5935c2 fix: type overriding 2025-06-04 12:33:22 +02:00
c27d837ab0 feat: get users endpoint 2025-06-04 12:33:06 +02:00
92e9b87227 feat: better types overriding 2025-06-04 12:32:47 +02:00
243b7cce33 fix: avatars width setting 2025-06-03 13:03:02 +02:00
76d960619f feat: api service edit page 2025-06-03 12:59:05 +02:00
3e59c78287 feat: api services updated modal 2025-06-03 12:58:57 +02:00
6cd9da69ab feat: update api service state 2025-06-03 12:58:40 +02:00
29b97a87b3 feat: link to edit page 2025-06-03 12:57:36 +02:00
8bc4603274 feat: update api service API 2025-06-03 12:54:20 +02:00
cc60a1ba86 feat: register edit service page in router 2025-06-03 12:54:10 +02:00
89c7dc43e5 feat: use updateapiservice endpoint 2025-06-03 12:53:57 +02:00
95c330568d feat: UI bind api service toggling 2025-06-03 00:07:09 +02:00
900d314a95 feat: don't require view service id 2025-06-03 00:07:00 +02:00
0d8a3b1b39 feat: store toggle active api service 2025-06-03 00:05:43 +02:00
4b7396c210 feat: toggle api service API 2025-06-03 00:05:32 +02:00
d4e2cbdd4f feat: activate api service query 2025-06-03 00:05:20 +02:00
5024ac8151 feat: register toggle endpoint 2025-06-03 00:05:05 +02:00
3bf08c5933 feat: toggle api service endpoint 2025-06-03 00:04:36 +02:00
b42da50306 feat: logout on error when refreshing token 2025-06-02 23:45:03 +02:00
0efc90567b feat: integrate oauth store in authorize page 2025-06-02 23:16:11 +02:00
a5466f1b10 feat: remove use of non existent oauth provider 2025-06-02 23:16:00 +02:00
8e946cbee5 feat: parse url search params on load in authlayout 2025-06-02 23:15:49 +02:00
a3a6b5e4d7 feat: create oauth store 2025-06-02 23:15:35 +02:00
ad0a0f5626 feat: remove context use 2025-06-02 23:15:29 +02:00
2389058ddc fix: use logout in both waiting and actual refreshing for/of the token 2025-06-02 23:15:21 +02:00
ce44ef3e62 feat: protect required endpoints by oauth 2025-06-02 23:15:02 +02:00
9ee30d1e23 feat: register /authorize route 2025-06-02 20:35:03 +02:00
886d0a7f5c feat: create middleware endpoint before accessing web ui interface for
authorize
2025-06-02 20:34:36 +02:00
cfdf419460 fix: use slices.Contains 2025-06-02 20:34:20 +02:00
930e069aee feat: authorize middleware to check api service activity 2025-06-02 13:00:19 +02:00
1ef261660f feat: split oauth endpoints into files 2025-06-02 12:31:01 +02:00
5d9e5d27bf fix: correct authorize uri 2025-06-01 21:22:16 +02:00
3f8a4024ce feat: .env file is optional 2025-06-01 12:55:05 +02:00
e8a74999c3 feat: dockerfile for image building 2025-06-01 12:54:44 +02:00
dc2ce1f349 fix: finish update service endpoint 2025-06-01 08:29:30 +02:00
bce775f692 feat: modify fields for updation 2025-05-31 23:19:30 +02:00
45bce711f2 feat: move routes to apiservices.go and add new ones 2025-05-31 23:19:15 +02:00
8ab2ddbe8e feat: responsiveness 2025-05-31 18:40:44 +02:00
7f9b719b2b feat: textarea border in light mode 2025-05-31 18:40:38 +02:00
2b7f4995ef feat: better border for table in light mode 2025-05-31 18:40:24 +02:00
321f4087e1 feat: delimiter color 2025-05-31 18:40:12 +02:00
9d19b470bc feat: register pages 2025-05-31 18:40:04 +02:00
68493be36e feat: redirect to not allowed page 2025-05-31 18:39:57 +02:00
f8772f8de2 feat: not allowed + not found pages 2025-05-31 18:39:49 +02:00
d451331c66 fix: enough padding for breadcrumbs 2025-05-31 17:59:11 +02:00
485cfc2d12 feat: API for getting single api service 2025-05-31 17:58:56 +02:00
944c650ab3 feat: view api service page 2025-05-31 17:58:40 +02:00
dfc5587608 feat: get single api service route 2025-05-31 17:58:31 +02:00
96bdbfda95 feat: description field 2025-05-31 17:58:19 +02:00
05a234b7a5 feat: find api service by id 2025-05-31 17:38:29 +02:00
2f58c01c24 feat: nowrap for heading 2025-05-31 17:31:43 +02:00
e92dde20ca feat: create api service PAI 2025-05-31 17:31:33 +02:00
63437d6dc7 feat: credentials modal 2025-05-31 17:31:22 +02:00
cef9dae4d3 feat: responsive breadcrumbsd 2025-05-31 17:31:06 +02:00
0d7b1355d5 feat: portal-root 2025-05-31 17:30:52 +02:00
a213ea85d0 feat: share description in dto 2025-05-31 17:30:44 +02:00
5c43f6d72a feat: accept description and is active fields 2025-05-31 17:30:07 +02:00
e49c0bbe45 feat: sqlc generate 2025-05-31 16:31:27 +02:00
413a11ee63 feat: accept description and is active fields on api service creation 2025-05-31 16:18:36 +02:00
4a112318bd feat: api services query add description and is active 2025-05-31 16:18:19 +02:00
9897eb1f5d feat: add api service page registetr 2025-05-31 16:16:43 +02:00
3fc7ceac23 feat: change border color for sidebar 2025-05-31 16:16:31 +02:00
aa48c21466 feat: better styles for UI input 2025-05-31 16:16:14 +02:00
0ca2bb3f89 feat: bar items better check for isActive 2025-05-31 16:16:04 +02:00
cd5adcdc3f feat: use breadcrubms on api services page 2025-05-31 16:15:47 +02:00
62c90d0597 fix: scroll content without sidebar 2025-05-31 16:15:31 +02:00
665d12a828 feat: create api service page 2025-05-31 16:15:22 +02:00
4d455fd62e feat: ui breadcrumbs component 2025-05-31 16:15:14 +02:00
348aacfde2 feat: add description column to api service 2025-05-31 16:15:07 +02:00
de17870bdb fix: add weirdo NUL (windows) file to git ignore 2025-05-31 10:27:34 +02:00
54581742dc feat: add API service button 2025-05-31 10:14:22 +02:00
639575dae0 feat: background for dashboard layout 2025-05-31 10:14:09 +02:00
800e1afbe5 feat: API service type 2025-05-30 21:28:06 +02:00
8abc4396ac feat: integrate admin store in api services tab 2025-05-30 21:27:59 +02:00
70f860824c feat: .air.toml file for ai 2025-05-30 21:27:46 +02:00
3c5e31cbb2 feat: api services DTO 2025-05-30 21:27:10 +02:00
3923b428a4 feat: admin zustand store 2025-05-30 21:27:02 +02:00
5b816c6873 feat: remove default inner padding for dashboard pages 2025-05-30 21:26:43 +02:00
66edadfeda feat: make hooks folder 2025-05-30 21:26:15 +02:00
b872722e07 fix: import bar items hook 2025-05-30 21:26:05 +02:00
091218b42d feat: better sidebar 2025-05-30 21:25:55 +02:00
03697b2f67 fix: window scoped state for token refresh 2025-05-30 21:25:39 +02:00
8a28fca3d9 feat: api for fetching api services 2025-05-30 21:25:15 +02:00
1ab4113040 feat: return all fields from api_service objects 2025-05-30 21:25:03 +02:00
013f300513 feat: adjust routes and make use of middlewares in each one 2025-05-30 21:24:48 +02:00
45e31b41ca feat: install pgxpool 2025-05-30 21:24:20 +02:00
182f30f1ba feat: use pgxpool instead of row connection 2025-05-30 21:24:07 +02:00
7c97ebd84f feat: let routes decide about middlewares to be used 2025-05-30 21:23:33 +02:00
9fefe3ac71 feat: makefile adjust for windows 2025-05-30 21:23:16 +02:00
ca3006c428 feat: ignore tmp/ folder 2025-05-30 21:23:02 +02:00
51b7e6b3f9 feat: admin routes + better auth routing 2025-05-30 18:17:12 +02:00
db2cb36f54 feat: location.pathname based bar navigation + admin layout + separate
auth routes
2025-05-29 23:42:17 +02:00
78e84567c7 feat: build and watch script 2025-05-29 20:13:34 +02:00
0423b3803f feat: api services tab for admin 2025-05-29 20:13:25 +02:00
60e317b9e4 feat: auth profile integration on personal info tab 2025-05-29 20:13:17 +02:00
aa18b9f3e2 feat: admin only bar items 2025-05-29 19:56:33 +02:00
d9ca1ce2b4 feat: profile picture fetching through guard service 2025-05-29 19:51:15 +02:00
41c3dfdfe4 chore: remove unnecessary console.logs 2025-05-29 17:19:13 +02:00
725cc74102 feat: prettier integration 2025-05-29 17:17:38 +02:00
83c26bb94a feat: explicitly install idb + use react-router instead of react-router-dom 2025-05-29 17:03:44 +02:00
6be3aa07a1 feat: add favicon 2025-05-29 17:03:05 +02:00
54021c3021 fix: correct path for skipping register auth require 2025-05-29 17:02:48 +02:00
4b3a814d7e fix: don't select account after logging into it 2025-05-29 14:57:35 +02:00
dd5c59afa8 feat: handle agree ('allow') button click to select account for oauth auth 2025-05-29 14:57:20 +02:00
0723a48ab0 feat: save search params 2025-05-29 14:57:01 +02:00
ffefee930a fix: don't select session for oauth after account selection 2025-05-29 14:56:37 +02:00
a7ddd3d1ff fix: use env issuer 2025-05-29 14:56:12 +02:00
21cedeabbd fix: proper page names 2025-05-29 14:19:01 +02:00
807d7538a0 feat: proper redirect handling 2025-05-29 14:17:09 +02:00
8364a8e9ec fix: avoid flash target page show 2025-05-29 12:43:39 +02:00
56755ac531 feat: navigate to home page after login if not explicit 2025-05-29 12:35:48 +02:00
e4d83e75a0 fmt: rename fetchProfile to authenticate 2025-05-29 12:35:07 +02:00
3dd91cf238 fix: proper token refreshing BEFORE sending req 2025-05-29 12:34:52 +02:00
03d6730151 feat: react-jwt pkg 2025-05-29 12:34:37 +02:00
aa152a4127 feat: authentication integration 2025-05-28 20:51:34 +02:00
a1ed1113d9 feat: fetch profile API 2025-05-28 17:40:33 +02:00
2187c873ee feat: json resposne 2025-05-28 17:40:24 +02:00
595015f324 feat: install zustand 2025-05-28 17:40:16 +02:00
8504f9c230 feat: small sidebar button text 2025-05-28 17:19:10 +02:00
04db9b8ef2 feat: unused import 2025-05-27 21:26:45 +02:00
e983719601 feat: tab navigation 2025-05-27 21:26:29 +02:00
c5c55f72b1 feat: preview page for account management 2025-05-25 17:51:37 +02:00
c445756296 feat: no scrollbar util 2025-05-25 17:51:26 +02:00
0166e62e98 feat: ability to remove spacing 2025-05-25 17:51:19 +02:00
8e22a3ac05 fix: cfg access 2025-05-25 16:52:10 +02:00
05ee30f6db feat: register api services rotues 2025-05-25 16:43:57 +02:00
dd8c453c54 feat: hash client secret 2025-05-25 16:43:17 +02:00
52870cb541 feat: util for generating client credentials 2025-05-25 16:40:58 +02:00
14b37c2220 feat: api services routes 2025-05-25 16:40:45 +02:00
5604a824fe feat: use issuer variable as base 2025-05-25 16:26:03 +02:00
7d0ddd4d77 feat: use config issuer for everything 2025-05-25 16:24:52 +02:00
07b9b94143 feat: refactor for using app config 2025-05-25 16:22:28 +02:00
b95dcc6230 feat: rename env vars + add jwt issuer 2025-05-25 16:18:30 +02:00
e8bad71f21 feat: smart config 2025-05-25 16:18:21 +02:00
4df7561dd3 feat: sqlc api_services generated cpde 2025-05-25 15:38:59 +02:00
8f753b2561 feat: api_services queries 2025-05-25 15:38:49 +02:00
11748bb68e feat: api services table migration 2025-05-25 15:38:40 +02:00
491c9a824d feat: remove unused util 2025-05-25 14:54:24 +02:00
476b9a13d9 feat: handle session selection after logging in 2025-05-25 14:54:19 +02:00
42665fffbb feat: handle account selection 2025-05-25 14:54:03 +02:00
a157a3ec0e feat: implement select session 2025-05-25 14:53:55 +02:00
024d07fdd6 feat: oauth context select session 2025-05-25 14:53:48 +02:00
23845e25dd feat: add admin role for all users for now 2025-05-25 14:53:38 +02:00
47209c311c feat: disable watch 2025-05-25 14:53:28 +02:00
2caef38ce6 feat: jwks endpoint + rsa keys use 2025-05-25 14:53:19 +02:00
e88980e64f feat: updated rsa key gen scripts 2025-05-25 14:53:02 +02:00
159e4ad0e2 feat: update ApiClaims 2025-05-25 14:16:30 +02:00
6e2d67ad24 feat: oauth endpoints: code and token 2025-05-25 14:16:20 +02:00
d46e296ce1 feat: respond with json 2025-05-25 14:15:50 +02:00
e98806e96f feat: JWT_KID env var 2025-05-25 14:14:35 +02:00
0ab82e2503 feat: disable authorization for /token + provide access to repo + register jwks endpoint 2025-05-25 14:13:05 +02:00
34c1ce7652 feat: generate code API 2025-05-25 14:12:27 +02:00
428dc50aa1 feat: oauth auth page 2025-05-24 20:59:56 +02:00
2663264f50 feat: register oauth authorize page 2025-05-24 20:59:48 +02:00
ffba961d72 fmt: don't use curly braces 2025-05-24 20:59:40 +02:00
5a939c0771 feat: link to login page to add account 2025-05-24 20:59:25 +02:00
87916f96fd feat: use oauth provider 2025-05-24 20:59:15 +02:00
c6c03e9cb6 fix: db context import 2025-05-24 20:59:04 +02:00
6fd7171450 feat: root dark overlay 2025-05-24 20:58:54 +02:00
ae07d2d3d9 feat: refactor db context 2025-05-24 20:58:36 +02:00
65545a0d71 feat: expose host 2025-05-24 20:58:09 +02:00
d423d9ba62 feat: oauth routes 2025-05-24 20:58:04 +02:00
d64c8479f8 feat: register oauth handler 2025-05-24 20:57:57 +02:00
3279f1fb90 feat: remove unused import 2025-05-24 18:02:25 +02:00
1819629008 test: profile pictures on agree preview 2025-05-24 17:51:16 +02:00
4e9fa2337b feat: change color 2025-05-24 17:46:16 +02:00
68899e98bd feat: profile picture support 2025-05-24 17:46:06 +02:00
47f5188961 feat: profile picture 2025-05-24 17:45:47 +02:00
1840194bae feat: use host env var 2025-05-24 17:45:37 +02:00
d3bcc785a1 feat: pass file storage instance 2025-05-24 17:17:54 +02:00
64faa4ca5f feat: update user model + update profile picture 2025-05-24 17:17:45 +02:00
24c72800ad feat: file storage 2025-05-24 17:17:31 +02:00
f559f54683 feat: upload avatar route 2025-05-24 17:17:25 +02:00
9b0de4512b feat: add profile_picture field to user table 2025-05-24 17:17:16 +02:00
b6d365cc48 feat: install minio v7 2025-05-24 17:16:55 +02:00
8f755b6d1e feat: minio env vars example 2025-05-24 17:16:45 +02:00
587a463623 feat: dark theme support 2025-05-24 16:15:22 +02:00
1a596eef87 feat: no rounded corners 2025-05-24 16:15:12 +02:00
e6b87a6561 feat: button variants 2025-05-24 16:15:00 +02:00
3bcc5f8900 fix: database wasn't opening 2025-05-24 16:14:53 +02:00
c5ee912408 feat: dark overlay in assets 2025-05-24 16:14:44 +02:00
9766da7cfd feat: icon in assets 2025-05-24 16:14:37 +02:00
a004a82272 feat: dark and light overlays in public 2025-05-24 16:14:25 +02:00
64ca9b922e feat: allow all hosts on dev 2025-05-24 16:14:15 +02:00
38a2ce1ce9 feat: start agreement page 2025-05-24 14:31:26 +02:00
accde2662f feat: account list 2025-05-24 14:31:16 +02:00
92fda8cb24 feat: separate interface for decoded accounts 2025-05-24 14:24:50 +02:00
eee3839dea feat: remove unnecessary comment 2025-05-24 14:24:39 +02:00
b941561ccf feat: render accounts saved in db 2025-05-24 14:24:29 +02:00
0bda5495c4 feat: connected field set 2025-05-24 14:24:22 +02:00
b5f5346536 feat: connected field in context 2025-05-24 14:23:56 +02:00
68e2ece877 feat: load all accounts req 2025-05-24 12:16:11 +02:00
04bd27607f feat: load all saved accounts 2025-05-24 12:15:51 +02:00
eb42b61b2c feat: store logged account 2025-05-24 12:05:57 +02:00
06e0e90677 feat: return id 2025-05-24 12:05:49 +02:00
eaf3596580 feat: database context 2025-05-24 11:19:16 +02:00
b8f3fa0a32 feat: unique device id 2025-05-21 22:10:46 +02:00
d4adc1b538 feat: refactoring 2025-05-21 21:59:50 +02:00
edfa3e63b9 feat: generate refresh token 2025-05-21 21:59:31 +02:00
7c58473ff1 feat: define ApiClaims 2025-05-21 21:59:15 +02:00
9473c83679 feat: hash utility 2025-05-21 21:17:55 +02:00
0b8c03e8c5 feat: hash user's password on register 2025-05-21 21:17:50 +02:00
55eb4c9862 feat: hash admin's password before creation 2025-05-21 21:17:41 +02:00
de28470432 feat: check passwords on login 2025-05-21 21:17:30 +02:00
8ccf9f281c feat: remove static folder 2025-05-21 19:28:10 +02:00
a9df6fa559 feat: remove unused rotues 2025-05-21 19:28:04 +02:00
eb9c2b1da1 feat: use register page 2025-05-21 19:27:17 +02:00
af8b347173 feat: finished login page 2025-05-21 19:27:08 +02:00
ba89880f8a feat: finished register page 2025-05-21 19:27:03 +02:00
07e1cbc66f feat: spread input props 2025-05-21 19:26:54 +02:00
aee3306c2b feat: proper button with loading 2025-05-21 19:26:41 +02:00
d2cb426170 feat: font 2025-05-21 19:26:31 +02:00
e9e1414c90 feat: dev proxy 2025-05-21 19:26:26 +02:00
4a71f6c5ee feat: react-hook-form pkg 2025-05-21 19:26:21 +02:00
a8e75d75f0 feat: accept host variable 2025-05-21 19:26:14 +02:00
afc9208269 feat: basic setup for web with tailwind and routing 2025-05-20 19:39:55 +02:00
ac07b5d723 feat: vite base 2025-05-20 18:46:01 +02:00
9267cf2618 feat: remove frontend bindings 2025-05-20 18:45:54 +02:00
55ccd8ea8e feat: serve frontend 2025-05-20 18:45:45 +02:00
fdf99d82e5 feat: out dir correction 2025-05-20 18:38:49 +02:00
d9c3223228 feat: react router + no swc 2025-05-20 18:38:01 +02:00
3f369de3fa feat: created react project sub directory 2025-05-20 18:30:15 +02:00
3cfc89eb39 feat: powershell generate keys script 2025-05-20 17:28:35 +02:00
6ea585cf23 feat: tidy 2025-05-20 17:28:27 +02:00
eba6253ff6 fix: make verify work with custom claims type 2025-05-19 16:37:27 +02:00
ef7e1a80d9 feat: login route + profile fetch 2025-05-19 16:37:12 +02:00
854e1b44a9 feat: find user by id query 2025-05-19 16:37:04 +02:00
607174110c feat: middleware types 2025-05-19 16:36:57 +02:00
c283998403 feat: claims type 2025-05-19 16:36:52 +02:00
986ca8e353 feat: request utils 2025-05-19 16:36:42 +02:00
20d9947642 feat: remove login route from user service 2025-05-19 16:36:37 +02:00
97e15e1b1e feat: find user by id 2025-05-19 16:36:28 +02:00
d50bd6c4f5 feat: auth middleware 2025-05-19 16:36:19 +02:00
cb91a10192 feat: auth handler init 2025-05-19 10:13:25 +02:00
1a2130992b feat: ignore .pem files 2025-05-19 09:19:28 +02:00
9fad610a70 fix: keys generation 2025-05-19 09:19:24 +02:00
1e7ac51ca0 feat: login post handler 2025-05-19 09:19:18 +02:00
8e181ccc07 feat: sign and verify token func 2025-05-19 09:19:10 +02:00
f63b0e9731 feat: env jwt variables 2025-05-19 08:19:10 +02:00
ca0d7930b1 feat: generate key pair for jwyt 2025-05-19 08:19:05 +02:00
6927ebf0d3 feat: install golang-jwt 2025-05-18 23:17:00 +02:00
00808c1c61 feat: login+register pages finish 2025-05-18 23:16:54 +02:00
0198d4c348 feat: javascript for login/register 2025-05-18 23:16:48 +02:00
c1b6143503 feat: login + register css 2025-05-18 23:16:40 +02:00
94a873f10a feat: custom http error in json format 2025-05-18 23:16:33 +02:00
703fd3174b feat: register route + repo accessing 2025-05-18 23:16:19 +02:00
584ee8865d feat: give access to repo 2025-05-18 23:16:07 +02:00
2c1ba0a8fb feat: transparent background for input 2025-05-18 20:24:06 +02:00
555417110b feat: extract port from .env 2025-05-18 20:24:00 +02:00
7fda8896ad feat: PORT field in .env 2025-05-18 20:21:10 +02:00
7fd104b6d0 feat: call godotenv 2025-05-18 20:20:30 +02:00
abddaaf51a feat: example .env file 2025-05-18 20:20:23 +02:00
7d6a521f12 feat: call admin ensure function 2025-05-18 20:16:26 +02:00
24a9c8a9ac feat: ensure admin user 2025-05-18 20:16:18 +02:00
e2a55588df feat: find user by email function 2025-05-18 20:16:10 +02:00
e7354df10e feat: phone number is not required 2025-05-18 20:15:58 +02:00
66b7df32f3 feat: added godotenv 2025-05-18 20:15:39 +02:00
9a48522aa7 feat: user updated with phone_number field 2025-05-18 20:05:01 +02:00
097eef0016 feat: pass root router 2025-05-18 20:04:36 +02:00
33c4dbaca9 feat: get both api and normal router 2025-05-18 20:04:27 +02:00
7b342a5fec feat: add phone number migration 2025-05-18 20:04:09 +02:00
49432dcbe5 feat: distributed styles 2025-05-18 19:56:12 +02:00
afd5b588d6 feat: image resources 2025-05-18 19:56:03 +02:00
c8d342d050 feat: templates for login and register 2025-05-18 19:55:55 +02:00
fa30f66b6b feat: fileserver + logging + styling 2025-05-18 17:00:07 +02:00
f382ccc008 feat: login + register html templates 2025-05-18 14:06:17 +02:00
512d76a380 feat: layout html 2025-05-18 14:06:02 +02:00
bc9b2d44b2 feat: login and register pages for testing 2025-05-18 14:05:55 +02:00
b7dbe1ef0d feat: render template web function 2025-05-18 14:05:42 +02:00
187 changed files with 17962 additions and 136 deletions

63
.air.toml Normal file
View 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
View 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
View File

@ -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
View 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"]

View File

@ -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
View File

@ -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 apps `.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 apps OIDC integration (see your apps 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 users 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 youd like to contribute or report bugs. HSP Guard is a personal home lab project, but feedback is always welcome!

View File

@ -4,30 +4,71 @@ 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
addr string
repo *repository.Queries
storage *storage.FileStorage
cache *cache.Client
cfg *config.AppConfig
}
func NewAPIServer(addr string, db *repository.Queries) *APIServer {
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage, cache *cache.Client, cfg *config.AppConfig) *APIServer {
return &APIServer{
addr: addr,
repo: db,
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()
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
@ -40,11 +81,10 @@ func (s *APIServer) Run() error {
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"}`)
_, _ = fmt.Fprint(w, `{"error": "405 - method not allowed"}`)
})
log.Println("Listening on", s.addr)
return http.ListenAndServe(s.addr, router)
}

View 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)
})
}

View File

@ -2,18 +2,37 @@ package main
import (
"context"
"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() {
err := godotenv.Load()
if err != nil && os.Getenv("ENV") != "production" {
log.Fatalln("WARNING: .env file not found. Skipping...")
}
var cfg config.AppConfig
err = config.LoadEnv(&cfg)
if err != nil {
log.Fatal(err)
return
}
ctx := context.Background()
conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
conn, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalln("ERR: Failed to connect to db:", err)
return
@ -21,7 +40,14 @@ func main() {
repo := repository.New(conn)
server := api.NewAPIServer(":3000", repo)
fStorage := storage.New(&cfg)
cache := cache.NewClient(&cfg)
user.EnsureAdminUser(ctx, &cfg, repo)
user.EnsureSystemPermissions(ctx, 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)
}

View File

@ -1,4 +1,3 @@
services:
db:
image: postgres
@ -7,6 +6,24 @@ services:
environment:
POSTGRES_USER: guard
POSTGRES_PASSWORD: guard
volumes:
- postgres-data:/var/lib/postgresql/data
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
postgres-data:
driver: local

32
go.mod
View File

@ -3,13 +3,35 @@ module gitea.local/admin/hspguard
go 1.24.3
require (
github.com/go-chi/chi/v5 v5.2.1 // indirect
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
)

55
go.sum
View File

@ -1,25 +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=

View 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)
}

View File

@ -0,0 +1,82 @@
package admin
import (
"encoding/json"
"log"
"net/http"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (h *AdminHandler) GetAllPermissions(w http.ResponseWriter, r *http.Request) {
rows, err := h.repo.GetGroupedPermissions(r.Context())
if err != nil {
log.Println("ERR: Failed to list permissions from db:", err)
web.Error(w, "failed to get all permissions", http.StatusInternalServerError)
return
}
type RowDTO struct {
Scope string `json:"scope"`
Permissions []repository.Permission `json:"permissions"`
}
if len(rows) == 0 {
rows = make([]repository.GetGroupedPermissionsRow, 0)
}
var mapped []RowDTO
for _, row := range rows {
var permissions []repository.Permission
if err := json.Unmarshal(row.Permissions, &permissions); err != nil {
log.Println("ERR: Failed to extract permissions from byte array:", err)
web.Error(w, "failed to get permissions", http.StatusInternalServerError)
return
}
mapped = append(mapped, RowDTO{
Scope: row.Scope,
Permissions: permissions,
})
}
if len(mapped) == 0 {
mapped = make([]RowDTO, 0)
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(mapped); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
func (h *AdminHandler) GetUserPermissions(w http.ResponseWriter, r *http.Request) {
userId := chi.URLParam(r, "user_id")
permissions, err := h.repo.GetUserPermissions(r.Context(), uuid.MustParse(userId))
if err != nil {
log.Println("ERR: Failed to list permissions from db:", err)
web.Error(w, "failed to get user permissions", http.StatusInternalServerError)
return
}
if len(permissions) == 0 {
permissions = make([]repository.Permission, 0)
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(permissions); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

156
internal/admin/roles.go Normal file
View File

@ -0,0 +1,156 @@
package admin
import (
"encoding/json"
"log"
"net/http"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (h *AdminHandler) GetAllRoles(w http.ResponseWriter, r *http.Request) {
rows, err := h.repo.GetRolesGroupedWithPermissions(r.Context())
if err != nil {
log.Println("ERR: Failed to list roles from db:", err)
web.Error(w, "failed to get all roles", http.StatusInternalServerError)
return
}
type RolePermissions struct {
Permissions []repository.Permission `json:"permissions"`
repository.Role
}
type RowDTO struct {
Scope string `json:"scope"`
Roles []RolePermissions `json:"roles"`
}
if len(rows) == 0 {
rows = make([]repository.GetRolesGroupedWithPermissionsRow, 0)
}
var mapped []RowDTO
for _, row := range rows {
var mappedRow RowDTO
mappedRow.Scope = row.Scope
if err := json.Unmarshal(row.Roles, &mappedRow.Roles); err != nil {
log.Println("ERR: Failed to extract roles from byte array:", err)
web.Error(w, "failed to get roles", http.StatusInternalServerError)
return
}
mapped = append(mapped, mappedRow)
}
if len(mapped) == 0 {
mapped = make([]RowDTO, 0)
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(mapped); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
func (h *AdminHandler) GetUserRoles(w http.ResponseWriter, r *http.Request) {
userId := chi.URLParam(r, "user_id")
parsed, err := uuid.Parse(userId)
if err != nil {
log.Printf("ERR: Received invalid UUID on get user roles '%s': %v\n", userId, err)
web.Error(w, "invalid user id", http.StatusBadRequest)
return
}
rows, err := h.repo.GetUserRoles(r.Context(), parsed)
if err != nil {
log.Println("ERR: Failed to list roles from db:", err)
web.Error(w, "failed to get user roles", http.StatusInternalServerError)
return
}
if len(rows) == 0 {
rows = make([]repository.Role, 0)
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(rows); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
type AssignRoleRequest struct {
RoleKey string `json:"role_key"`
}
func (h *AdminHandler) AssignUserRole(w http.ResponseWriter, r *http.Request) {
userId := chi.URLParam(r, "user_id")
var req AssignRoleRequest
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.RoleKey == "" {
web.Error(w, "role key is required for assign", http.StatusBadRequest)
return
}
parsed, err := uuid.Parse(userId)
if err != nil {
log.Printf("ERR: Failed to parse provided user ID '%s': %v\n", userId, err)
web.Error(w, "invalid user id provided", http.StatusBadRequest)
return
}
user, err := h.repo.FindUserId(r.Context(), parsed)
if err != nil {
web.Error(w, "no user found under provided id", http.StatusBadRequest)
return
}
if _, err := h.repo.FindUserRole(r.Context(), repository.FindUserRoleParams{
UserID: user.ID,
Key: req.RoleKey,
}); err == nil {
log.Printf("INFO: Unassigning role '%s' for user with '%s' id", req.RoleKey, user.ID.String())
// Unassign Role
if err := h.repo.UnassignUserRole(r.Context(), repository.UnassignUserRoleParams{
UserID: user.ID,
Key: req.RoleKey,
}); err != nil {
log.Printf("ERR: Failed to unassign role '%s' from user with '%s' id: %v\n", req.RoleKey, user.ID.String(), err)
web.Error(w, "failed to unassign role to user", http.StatusInternalServerError)
return
}
} else {
log.Printf("INFO: Assigning role '%s' for user with '%s' id", req.RoleKey, user.ID.String())
if err := h.repo.AssignUserRole(r.Context(), repository.AssignUserRoleParams{
UserID: user.ID,
Key: req.RoleKey,
}); err != nil {
log.Printf("ERR: Failed to assign role '%s' to user with '%s' id: %v\n", req.RoleKey, user.ID.String(), err)
web.Error(w, "failed to assign role to user", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
}

54
internal/admin/routes.go Normal file
View File

@ -0,0 +1,54 @@
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)
r.Get("/permissions", h.GetAllPermissions)
r.Get("/permissions/{user_id}", h.GetUserPermissions)
r.Get("/roles", h.GetAllRoles)
r.Get("/roles/{user_id}", h.GetUserRoles)
r.Patch("/roles/{user_id}", h.AssignUserRole)
})
router.Get("/api-services/client/{client_id}", h.GetApiServiceCID)
}

182
internal/admin/sessions.go Normal file
View 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
View 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
View 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(&params); 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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)
})
}

View 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)
})
}
}

View 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
View 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
View 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(), &params); 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
View 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
View 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
View 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
View 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)
}
}

View 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
}

View File

@ -5,17 +5,102 @@
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 Permission struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
Description *string `json:"description"`
}
type Role struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
Description *string `json:"description"`
}
type RolePermission struct {
RoleID uuid.UUID `json:"role_id"`
PermissionID uuid.UUID `json:"permission_id"`
}
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 UserPermission struct {
UserID uuid.UUID `json:"user_id"`
PermissionID uuid.UUID `json:"permission_id"`
}
type UserRole struct {
UserID uuid.UUID `json:"user_id"`
RoleID uuid.UUID `json:"role_id"`
}
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"`
}

View File

@ -0,0 +1,156 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: permissions.sql
package repository
import (
"context"
"github.com/google/uuid"
)
const createPermission = `-- name: CreatePermission :one
INSERT into permissions (
name, scope, description
) VALUES (
$1, $2, $3
) RETURNING id, name, scope, description
`
type CreatePermissionParams struct {
Name string `json:"name"`
Scope string `json:"scope"`
Description *string `json:"description"`
}
func (q *Queries) CreatePermission(ctx context.Context, arg CreatePermissionParams) (Permission, error) {
row := q.db.QueryRow(ctx, createPermission, arg.Name, arg.Scope, arg.Description)
var i Permission
err := row.Scan(
&i.ID,
&i.Name,
&i.Scope,
&i.Description,
)
return i, err
}
const findPermission = `-- name: FindPermission :one
SELECT id, name, scope, description FROM permissions
WHERE name = $1 AND scope = $2
`
type FindPermissionParams struct {
Name string `json:"name"`
Scope string `json:"scope"`
}
func (q *Queries) FindPermission(ctx context.Context, arg FindPermissionParams) (Permission, error) {
row := q.db.QueryRow(ctx, findPermission, arg.Name, arg.Scope)
var i Permission
err := row.Scan(
&i.ID,
&i.Name,
&i.Scope,
&i.Description,
)
return i, err
}
const getAllPermissions = `-- name: GetAllPermissions :many
SELECT id, name, scope, description
FROM permissions p
ORDER BY p.scope
`
func (q *Queries) GetAllPermissions(ctx context.Context) ([]Permission, error) {
rows, err := q.db.Query(ctx, getAllPermissions)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Permission
for rows.Next() {
var i Permission
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Scope,
&i.Description,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getGroupedPermissions = `-- name: GetGroupedPermissions :many
SELECT scope, json_agg(to_jsonb(permissions.*) ORDER BY name) as permissions
FROM permissions
GROUP BY scope
`
type GetGroupedPermissionsRow struct {
Scope string `json:"scope"`
Permissions []byte `json:"permissions"`
}
func (q *Queries) GetGroupedPermissions(ctx context.Context) ([]GetGroupedPermissionsRow, error) {
rows, err := q.db.Query(ctx, getGroupedPermissions)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetGroupedPermissionsRow
for rows.Next() {
var i GetGroupedPermissionsRow
if err := rows.Scan(&i.Scope, &i.Permissions); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserPermissions = `-- name: GetUserPermissions :many
SELECT DISTINCT p.id,p.name,p.scope,p.description
FROM user_roles ur
JOIN role_permissions rp ON ur.role_id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id
WHERE ur.user_id = $1
ORDER BY p.scope
`
func (q *Queries) GetUserPermissions(ctx context.Context, userID uuid.UUID) ([]Permission, error) {
rows, err := q.db.Query(ctx, getUserPermissions, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Permission
for rows.Next() {
var i Permission
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Scope,
&i.Description,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -0,0 +1,282 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: roles.sql
package repository
import (
"context"
"github.com/google/uuid"
)
const addPermissionsToRoleByKey = `-- name: AddPermissionsToRoleByKey :exec
INSERT INTO role_permissions (role_id, permission_id)
SELECT
$1,
p.id
FROM
permissions p
JOIN
unnest($2::text[]) AS key_str
ON key_str = p.scope || '_' || p.name
`
type AddPermissionsToRoleByKeyParams struct {
RoleID uuid.UUID `json:"role_id"`
PermissionKeys []string `json:"permission_keys"`
}
func (q *Queries) AddPermissionsToRoleByKey(ctx context.Context, arg AddPermissionsToRoleByKeyParams) error {
_, err := q.db.Exec(ctx, addPermissionsToRoleByKey, arg.RoleID, arg.PermissionKeys)
return err
}
const assignRolePermission = `-- name: AssignRolePermission :exec
INSERT INTO role_permissions (role_id, permission_id)
VALUES (
$1,
(
SELECT id
FROM permissions p
WHERE p.scope = split_part($2, '_', 1)
AND p.name = right($2, length($2) - position('_' IN $2))
)
)
`
type AssignRolePermissionParams struct {
RoleID uuid.UUID `json:"role_id"`
Key string `json:"key"`
}
func (q *Queries) AssignRolePermission(ctx context.Context, arg AssignRolePermissionParams) error {
_, err := q.db.Exec(ctx, assignRolePermission, arg.RoleID, arg.Key)
return err
}
const assignUserRole = `-- name: AssignUserRole :exec
INSERT INTO user_roles (user_id, role_id)
VALUES ($1, (
SELECT id FROM roles r
WHERE r.scope = split_part($2, '_', 1)
AND r.name = right($2, length($2) - position('_' IN $2))
))
`
type AssignUserRoleParams struct {
UserID uuid.UUID `json:"user_id"`
Key string `json:"key"`
}
func (q *Queries) AssignUserRole(ctx context.Context, arg AssignUserRoleParams) error {
_, err := q.db.Exec(ctx, assignUserRole, arg.UserID, arg.Key)
return err
}
const createRole = `-- name: CreateRole :one
INSERT INTO roles (name, scope, description)
VALUES ($1, $2, $3)
RETURNING id, name, scope, description
`
type CreateRoleParams struct {
Name string `json:"name"`
Scope string `json:"scope"`
Description *string `json:"description"`
}
func (q *Queries) CreateRole(ctx context.Context, arg CreateRoleParams) (Role, error) {
row := q.db.QueryRow(ctx, createRole, arg.Name, arg.Scope, arg.Description)
var i Role
err := row.Scan(
&i.ID,
&i.Name,
&i.Scope,
&i.Description,
)
return i, err
}
const findRole = `-- name: FindRole :one
SELECT id, name, scope, description
FROM roles
WHERE scope = $1 AND name = $2
`
type FindRoleParams struct {
Scope string `json:"scope"`
Name string `json:"name"`
}
func (q *Queries) FindRole(ctx context.Context, arg FindRoleParams) (Role, error) {
row := q.db.QueryRow(ctx, findRole, arg.Scope, arg.Name)
var i Role
err := row.Scan(
&i.ID,
&i.Name,
&i.Scope,
&i.Description,
)
return i, err
}
const findUserRole = `-- name: FindUserRole :one
SELECT user_id, role_id FROM user_roles
WHERE user_id = $1 AND role_id = (SELECT id FROM roles r WHERE r.scope = split_part($2, '_', 1) AND r.name = right($2, length($2) - position('_' IN $2)))
LIMIT 1
`
type FindUserRoleParams struct {
UserID uuid.UUID `json:"user_id"`
Key string `json:"key"`
}
func (q *Queries) FindUserRole(ctx context.Context, arg FindUserRoleParams) (UserRole, error) {
row := q.db.QueryRow(ctx, findUserRole, arg.UserID, arg.Key)
var i UserRole
err := row.Scan(&i.UserID, &i.RoleID)
return i, err
}
const getRoleAssignment = `-- name: GetRoleAssignment :one
SELECT role_id, permission_id FROM role_permissions
WHERE role_id = $1 AND permission_id = (SELECT id FROM permissions p WHERE p.scope = split_part($2, '_', 1) AND p.name = right($2, length($2) - position('_' IN $2)))
LIMIT 1
`
type GetRoleAssignmentParams struct {
RoleID uuid.UUID `json:"role_id"`
Key string `json:"key"`
}
func (q *Queries) GetRoleAssignment(ctx context.Context, arg GetRoleAssignmentParams) (RolePermission, error) {
row := q.db.QueryRow(ctx, getRoleAssignment, arg.RoleID, arg.Key)
var i RolePermission
err := row.Scan(&i.RoleID, &i.PermissionID)
return i, err
}
const getRolesGroupedWithPermissions = `-- name: GetRolesGroupedWithPermissions :many
SELECT
r.scope,
json_agg (
json_build_object (
'id',
r.id,
'name',
r.name,
'scope',
r.scope,
'description',
r.description,
'permissions',
COALESCE(p_list.permissions, '[]')
)
ORDER BY
r.name
) AS roles
FROM
roles r
LEFT JOIN (
SELECT
rp.role_id,
json_agg (
json_build_object (
'id',
p.id,
'name',
p.name,
'scope',
p.scope,
'description',
p.description
)
ORDER BY
p.name
) AS permissions
FROM
role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
GROUP BY
rp.role_id
) p_list ON p_list.role_id = r.id
GROUP BY
r.scope
`
type GetRolesGroupedWithPermissionsRow struct {
Scope string `json:"scope"`
Roles []byte `json:"roles"`
}
func (q *Queries) GetRolesGroupedWithPermissions(ctx context.Context) ([]GetRolesGroupedWithPermissionsRow, error) {
rows, err := q.db.Query(ctx, getRolesGroupedWithPermissions)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetRolesGroupedWithPermissionsRow
for rows.Next() {
var i GetRolesGroupedWithPermissionsRow
if err := rows.Scan(&i.Scope, &i.Roles); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserRoles = `-- name: GetUserRoles :many
SELECT r.id, r.name, r.scope, r.description FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = $1
`
func (q *Queries) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]Role, error) {
rows, err := q.db.Query(ctx, getUserRoles, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Role
for rows.Next() {
var i Role
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Scope,
&i.Description,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const unassignUserRole = `-- name: UnassignUserRole :exec
DELETE FROM user_roles
WHERE user_id = $1 AND role_id = (
SELECT id FROM roles r
WHERE r.scope = split_part($2, '_', 1)
AND r.name = right($2, length($2) - position('_' IN $2))
)
`
type UnassignUserRoleParams struct {
UserID uuid.UUID `json:"user_id"`
Key string `json:"key"`
}
func (q *Queries) UnassignUserRole(ctx context.Context, arg UnassignUserRoleParams) error {
_, err := q.db.Exec(ctx, unassignUserRole, arg.UserID, arg.Key)
return err
}

View 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
}

View 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
}

View File

@ -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
View 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()
}

View 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
View 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
View 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"`
}

View 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
View 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
View 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
View 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
View 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,
})
}

View File

@ -0,0 +1,215 @@
package user
import (
"context"
"log"
"gitea.local/admin/hspguard/internal/repository"
)
func String(s string) *string {
return &s
}
type RolePermissions struct {
Permissions []string `json:"permissions"`
repository.Role
}
var (
SYSTEM_SCOPE string = "system"
SYSTEM_PERMISSIONS []repository.Permission = []repository.Permission{
{
Name: "log_into_guard",
Description: String("Allow users to log into their accounts"),
},
{
Name: "register",
Description: String("Allow users to register new accounts"),
},
{
Name: "edit_profile",
Description: String("Allow users to edit their profiles"),
},
{
Name: "recover_credentials",
Description: String("Allow users to recover their password/email"),
},
{
Name: "verify_profile",
Description: String("Allow users to verify their accounts"),
},
{
Name: "access_home_services",
Description: String("Allow users to access home services and tools"),
},
{
Name: "view_sessions",
Description: String("Allow users to view their active sessions"),
},
{
Name: "revoke_sessions",
Description: String("Allow users to revoke their active sessions"),
},
{
Name: "view_api_services",
Description: String("Allow users to view API Services (for admin)"),
},
{
Name: "add_api_services",
Description: String("Allow users to register new API Services (for admin)"),
},
{
Name: "edit_api_services",
Description: String("Allow users to edit API Services (for admin)"),
},
{
Name: "remove_api_services",
Description: String("Allow users to remove API Services (for admin)"),
},
{
Name: "view_users",
Description: String("Allow users to view other users (for admin)"),
},
{
Name: "add_users",
Description: String("Allow users to create new users (for admin)"),
},
// TODO: block, delete users
{
Name: "view_user_sessions",
Description: String("Allow users to view user sessions (for admin)"),
},
{
Name: "revoke_user_sessions",
Description: String("Allow users to revoke user sessions (for admin)"),
},
{
Name: "view_service_sessions",
Description: String("Allow users to view service sessions (for admin)"),
},
{
Name: "revoke_service_sessions",
Description: String("Allow users to revoke service sessions (for admin)"),
},
{
Name: "view_permissions",
Description: String("Allow users to view all permissions (for admin)"),
},
{
Name: "view_roles_groups",
Description: String("Allow users to view roles & groups (for admin)"),
},
}
SYSTEM_ROLES []RolePermissions = []RolePermissions{
{
Permissions: []string{
"system_log_into_guard",
"system_register",
"system_edit_profile",
"system_recover_credentials",
"system_verify_profile",
"system_access_home_services",
"system_view_sessions",
"system_revoke_sessions",
"system_view_api_services",
"system_add_api_services",
"system_edit_api_services",
"system_remove_api_services",
"system_view_users",
"system_add_users",
"system_view_user_sessions",
"system_revoke_user_sessions",
"system_view_service_sessions",
"system_revoke_service_sessions",
"system_view_permissions",
"system_view_roles_groups",
},
Role: repository.Role{
Name: "admin",
Description: String("User with full power"),
},
},
{
Permissions: []string{
"system_log_into_guard",
"system_register",
"system_edit_profile",
"system_recover_credentials",
"system_verify_profile",
"system_access_home_services",
"system_view_sessions",
"system_revoke_sessions",
},
Role: repository.Role{
Name: "member",
Description: String("User that is able to use home services"),
},
},
{
Permissions: []string{
"system_log_into_guard",
"system_register",
},
Role: repository.Role{
Name: "guest",
Description: String("New user that needs approve for everything from admin"),
},
},
}
)
func EnsureSystemPermissions(ctx context.Context, repo *repository.Queries) {
for _, permission := range SYSTEM_PERMISSIONS {
_, err := repo.FindPermission(ctx, repository.FindPermissionParams{
Name: permission.Name,
Scope: SYSTEM_SCOPE,
})
if err != nil {
log.Printf("INFO: Creating SYSTEM permission: '%s'\n", permission.Name)
_, err = repo.CreatePermission(ctx, repository.CreatePermissionParams{
Name: permission.Name,
Scope: SYSTEM_SCOPE,
Description: permission.Description,
})
if err != nil {
log.Fatalf("ERR: Failed to create SYSTEM permission: '%s'\n", permission.Name)
}
}
}
for _, role := range SYSTEM_ROLES {
var found repository.Role
var err error
found, err = repo.FindRole(ctx, repository.FindRoleParams{
Scope: SYSTEM_SCOPE,
Name: role.Name,
})
if err != nil {
log.Printf("INFO: Create new SYSTEM role '%s'\n", role.Name)
found, err = repo.CreateRole(ctx, repository.CreateRoleParams{
Name: role.Name,
Scope: SYSTEM_SCOPE,
Description: role.Description,
})
if err != nil {
log.Fatalf("ERR: Failed to create SYSTEM role '%s': %v\n", role.Name, err)
}
}
for _, perm := range role.Permissions {
if _, exists := repo.GetRoleAssignment(ctx, repository.GetRoleAssignmentParams{
RoleID: found.ID,
Key: perm,
}); exists != nil {
if err := repo.AssignRolePermission(ctx, repository.AssignRolePermissionParams{
RoleID: found.ID,
Key: perm,
}); err != nil {
log.Fatalf("ERR: Failed to assign permission '%s' to SYSTEM role %s: %v\n", perm, found.Name, err)
}
}
}
}
}

View File

@ -1,23 +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 {}
func NewUserHandler() *UserHandler {
return &UserHandler{}
type UserHandler struct {
repo *repository.Queries
minio *storage.FileStorage
cfg *config.AppConfig
}
func (h *UserHandler) RegisterRoutes(router chi.Router) {
router.Get("/login", h.handleLogin)
func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage, cfg *config.AppConfig) *UserHandler {
return &UserHandler{
repo,
minio,
cfg,
}
}
func (h *UserHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "/GET Hello, user from %s", r.RemoteAddr)
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(&params); 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -0,0 +1,55 @@
-- +goose Up
-- +goose StatementBegin
-- ROLES
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
name TEXT NOT NULL,
scope TEXT NOT NULL,
description TEXT,
UNIQUE (name, scope)
);
-- PERMISSIONS
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
name TEXT NOT NULL,
scope TEXT NOT NULL,
description TEXT,
UNIQUE (name, scope)
);
-- ROLE-PERMISSIONS (many-to-many)
CREATE TABLE role_permissions (
role_id UUID REFERENCES roles (id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions (id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- USER-ROLES (direct assignment, optional)
CREATE TABLE user_roles (
user_id UUID REFERENCES users (id) ON DELETE CASCADE,
role_id UUID REFERENCES roles (id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-- USER-PERMISSIONS (direct assignment, optional)
CREATE TABLE user_permissions (
user_id UUID REFERENCES users (id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions (id) ON DELETE CASCADE,
PRIMARY KEY (user_id, permission_id)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS user_permissions;
DROP TABLE IF EXISTS user_roles;
DROP TABLE IF EXISTS role_permissions;
DROP TABLE IF EXISTS permissions;
DROP TABLE IF EXISTS roles;
-- +goose StatementEnd

51
queries/api_services.sql Normal file
View 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;

29
queries/permissions.sql Normal file
View File

@ -0,0 +1,29 @@
-- name: GetAllPermissions :many
SELECT *
FROM permissions p
ORDER BY p.scope;
-- name: GetGroupedPermissions :many
SELECT scope, json_agg(to_jsonb(permissions.*) ORDER BY name) as permissions
FROM permissions
GROUP BY scope;
-- name: CreatePermission :one
INSERT into permissions (
name, scope, description
) VALUES (
$1, $2, $3
) RETURNING *;
-- name: FindPermission :one
SELECT * FROM permissions
WHERE name = $1 AND scope = $2;
-- name: GetUserPermissions :many
SELECT DISTINCT p.id,p.name,p.scope,p.description
FROM user_roles ur
JOIN role_permissions rp ON ur.role_id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id
WHERE ur.user_id = $1
ORDER BY p.scope;

109
queries/roles.sql Normal file
View File

@ -0,0 +1,109 @@
-- name: FindRole :one
SELECT *
FROM roles
WHERE scope = $1 AND name = $2;
-- name: GetRolesGroupedWithPermissions :many
SELECT
r.scope,
json_agg (
json_build_object (
'id',
r.id,
'name',
r.name,
'scope',
r.scope,
'description',
r.description,
'permissions',
COALESCE(p_list.permissions, '[]')
)
ORDER BY
r.name
) AS roles
FROM
roles r
LEFT JOIN (
SELECT
rp.role_id,
json_agg (
json_build_object (
'id',
p.id,
'name',
p.name,
'scope',
p.scope,
'description',
p.description
)
ORDER BY
p.name
) AS permissions
FROM
role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
GROUP BY
rp.role_id
) p_list ON p_list.role_id = r.id
GROUP BY
r.scope;
-- name: CreateRole :one
INSERT INTO roles (name, scope, description)
VALUES ($1, $2, $3)
RETURNING *;
-- name: GetRoleAssignment :one
SELECT * FROM role_permissions
WHERE role_id = $1 AND permission_id = (SELECT id FROM permissions p WHERE p.scope = split_part(sqlc.arg('key'), '_', 1) AND p.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key'))))
LIMIT 1;
-- name: AssignRolePermission :exec
INSERT INTO role_permissions (role_id, permission_id)
VALUES (
$1,
(
SELECT id
FROM permissions p
WHERE p.scope = split_part(sqlc.arg('key'), '_', 1)
AND p.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key')))
)
);
-- name: AddPermissionsToRoleByKey :exec
INSERT INTO role_permissions (role_id, permission_id)
SELECT
sqlc.arg(role_id),
p.id
FROM
permissions p
JOIN
unnest(sqlc.arg(permission_keys)::text[]) AS key_str
ON key_str = p.scope || '_' || p.name;
-- name: GetUserRoles :many
SELECT r.* FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = $1;
-- name: AssignUserRole :exec
INSERT INTO user_roles (user_id, role_id)
VALUES ($1, (
SELECT id FROM roles r
WHERE r.scope = split_part(sqlc.arg('key'), '_', 1)
AND r.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key')))
));
-- name: UnassignUserRole :exec
DELETE FROM user_roles
WHERE user_id = $1 AND role_id = (
SELECT id FROM roles r
WHERE r.scope = split_part(sqlc.arg('key'), '_', 1)
AND r.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key')))
);
-- name: FindUserRole :one
SELECT * FROM user_roles
WHERE user_id = $1 AND role_id = (SELECT id FROM roles r WHERE r.scope = split_part(sqlc.arg('key'), '_', 1) AND r.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key'))))
LIMIT 1;

View 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
View 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;

View File

@ -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
View File

@ -0,0 +1,4 @@
# Enable ACL
user default off
user guard on >guard allcommands allkeys

View 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
View 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
View File

@ -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

View 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 }}

View File

@ -0,0 +1,5 @@
{{ define "footer" }}
<link rel="stylesheet" href="/static/css/footer.css">
<footer class="footer"><p>&copy; 2025 HSP Guard</p></footer>
{{ end }}

View 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 }}

View 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
View 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
View File

@ -0,0 +1,5 @@
# Ignore artifacts:
build
coverage
node_modules
public

1
web/.prettierrc Normal file
View File

@ -0,0 +1 @@
{}

54
web/README.md Normal file
View 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
View 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
View File

@ -0,0 +1,6 @@
interface Window {
guard: {
refreshing: boolean;
refreshQueue: ((token: string | null) => void)[];
};
}

14
web/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

46
web/package.json Normal file
View 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"
}
}

Some files were not shown because too many files have changed in this diff Show More