- Kotlin 86.6%
- TypeScript 10.2%
- CSS 2.1%
- Dockerfile 0.8%
- HTML 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| frontend | ||
| gradle/wrapper | ||
| jenkins | ||
| monitoring | ||
| nginx | ||
| scripts | ||
| src | ||
| .dockerignore | ||
| .gitignore | ||
| build.gradle.kts | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.ci | ||
| gradlew | ||
| gradlew.bat | ||
| linklab.postman_collection.json | ||
| README.md | ||
| settings.gradle.kts | ||
LinkLab — Week 7 Blueprint
CS4297 Applied System Design · Security: JWT, OAuth2 and RBAC
Complete Week 6 solution included. Clone this and start here.
What you are building this week
| Part | What happens | Time |
|---|---|---|
| A | Naive session auth — plain-text passwords and a server-side session, then watch it break behind NGINX | 25 min |
| B | JWT — implement register/login returning signed tokens | 45 min |
| C | RBAC — add @PreAuthorize to three admin endpoints |
25 min |
| Week 7 Optional 1 | GitHub OAuth2 — login with GitHub, get a JWT | 30 min |
| Week 7 Optional 2 | Refresh tokens — short-lived access + long-lived refresh | 30 min |
| Week 7 Optional 3 | Rate limiting — lock out after 5 failed login attempts | 30 min |
Weeks 4 and 6 left optional tasks of their own in this code, numbered 1-3 as
well, so three unrelated "Optional Task 1" exist. They are still worth doing:
grep for WEEK 4 OPTIONAL TASK or WEEK 6 OPTIONAL TASK to find them in the
source. This week's three are described below and have no stubs in the code.
Prerequisites
- JDK 21 — check with
java -version - Docker Desktop with at least 6 GB RAM (Settings → Resources → Memory)
- Forgejo account at forgejo.skynet.ie
Getting started
1. Start the infrastructure
docker compose up -d mysql redis dynamodb-local elasticsearch kafka
./gradlew bootRun
Part A needs two application instances behind NGINX, and those are built from source inside Docker. Start that build early — it compiles the whole project in a container and takes several minutes the first time:
docker compose build link-service-1 link-service-2
It builds from the files on disk, not from your editor's buffer, and it does
not rebuild on its own. Every time you change code, build again before
docker compose up -d link-service-1 link-service-2.
2. Start the frontend and open it
docker compose up -d --build frontend
Visit http://localhost:3001
The frontend is served by a Vite dev server in Docker with hot reload.
Edit files in frontend/src/ and the browser updates automatically.
3. Run the tests
./gradlew test
- All Week 6 tests pass (solution complete)
AuthControllerTest— 5 of 6 tests fail until you implement register and login. (The 6th, the short-password case, passes already —@Validrejects it before your code ever runs.)SearchControllerTest,LinkControllerTest,KafkaOptionalControllerTest— one test each (... as non-admin should return 403) fails until you add@PreAuthorize("hasRole('ADMIN')")toreindex(),stats(), andlag()respectively (Part C). Everything else in those files passes already.
What to implement
src/main/kotlin/ie/ul/cs4297/linklab/auth/
└── AuthController.kt # WEEK 7 TASK: implement register() and login()
src/main/kotlin/ie/ul/cs4297/linklab/controller/
├── SearchController.kt # WEEK 7 TASK: add @PreAuthorize to reindex()
├── LinkController.kt # WEEK 7 TASK: add @PreAuthorize to stats()
└── KafkaOptionalController.kt # WEEK 7 TASK: add @PreAuthorize to lag()
Already provided (do not modify):
JwtUtil.kt— generates and validates JWTsJwtAuthFilter.kt— extracts Bearer token from every requestSecurityConfig.kt— Spring Security filter chain, CORS, public endpointsOAuth2SuccessHandler.kt— issues JWT after GitHub OAuth2 loginLinkLabUserDetailsService.kt— loads users from MySQLUser.kt+UserRepository.kt— user entity and repository- The full React frontend in
frontend/
Role model
| Role | Endpoints |
|---|---|
| anyone, no token | POST /api/auth/**, GET /{code}, GET /api/links/search, /actuator/** |
ROLE_USER |
the above + POST /api/links and everything else that is not admin-only |
ROLE_ADMIN |
all of the above + POST /api/links/reindex, GET /api/links/{code}/stats, GET /api/optional/kafka/lag/{groupId} |
The redirect and the search endpoint are deliberately public — a short link
nobody can follow without logging in is not a short link. SecurityConfig is
where that is decided.
Register users with different roles to test:
# Regular user
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password123","role":"ROLE_USER"}'
# Admin
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"password123","role":"ROLE_ADMIN"}'
Finding your JWT in the browser
After logging in via the frontend, your token is stored in localStorage.
To find it:
- Open browser DevTools (
F12orCmd+Option+I) - Go to Application tab (Chrome) or Storage tab (Firefox)
- Expand Local Storage →
http://localhost:3001 - Click
token— copy the value
You can then paste it into jwt.io to inspect the claims, or
into the token collection variable in linklab.postman_collection.json —
the collection sends it as a Bearer token on every request.
JWT quick reference
# Register and get token
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password123"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# Use token
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/links \
-X POST -H "Content-Type: application/json" -d '{"url":"https://ul.ie"}'
# Paste token at https://jwt.io to inspect claims
Week 7 Optional Task 1 — GitHub OAuth2
- github.com/settings/developers → New OAuth App
- Homepage URL:
http://localhost:3001 - Callback URL:
http://localhost:8080/login/oauth2/code/github - Add Client ID and Secret to
application.properties - Restart, click "Continue with GitHub" on the login page
Week-by-week progression
| Week | Content | Blueprint |
|---|---|---|
| 2 | REST API + MySQL | Stubs for LINK-001 |
| 3 | Docker · Redis · NGINX · Grafana | Complete Week 2 |
| 4 | SQL vs NoSQL · DynamoDB | Complete Week 3 |
| 5 | CI/CD · ElasticSearch · Fan-out | Complete Week 4 |
| 6 | Kafka async messaging + scale | Complete Week 5 |
| 7 | Security: JWT, OAuth2 and RBAC | ← you are here |
| 8 | Capstone: full integration | Complete Week 7 |
| 9 | Capstone: demo and review | Complete Week 8 |
Common issues
Your own controller tests fail with 403 Forbidden
Most endpoints require authentication now. Add @WithMockUser (class-level or
per-test) to any test hitting a protected endpoint — see LinkControllerTest,
SearchControllerTest, or KafkaOptionalControllerTest for examples, including
how to override to roles = ["ADMIN"] for a specific test. AuthControllerTest
doesn't need this — /api/auth/** is public.
Frontend shows blank page
Check docker compose logs frontend. If it says vite: not found, run docker compose build frontend.
POST /api/auth/register returns 405 Method Not Allowed
Spring Security is intercepting before your controller. Check SecurityConfig permits /api/auth/**.
GitHub OAuth2 redirect fails
Callback URL in GitHub app settings must match exactly: http://localhost:8080/login/oauth2/code/github
A protected endpoint answers 401 and you did send a token
The token is expired (one hour) or was signed with a different
linklab.jwt.secret. Register again and use the new token. If you lowered
linklab.jwt.expiry-seconds while experimenting, put it back to 3600.
You lowered linklab.jwt.expiry-seconds, waited, and the token still works
Only the process that re-read application.properties changed. ./gradlew bootRun on port 8080 picks the new value up on restart; link-service-1 and
link-service-2 behind NGINX on port 80 keep the old one until you
docker compose build them again. The expiresIn field in the register
response tells you which instance answered.
Your code changes have no effect on port 80
Port 80 is NGINX in front of two containers built from a snapshot of your
source. They never rebuild themselves: run docker compose build link-service-1 link-service-2, then docker compose up -d link-service-1 link-service-2, and check that up reports Recreated or Started. If it
prints nothing, the build failed — scroll up and read it.
Everything you registered has disappeared
spring.jpa.hibernate.ddl-auto=create-drop drops and recreates the tables on
every start, so restarting either instance empties the users table. Register
again after a restart, and register against the pair only once both instances
are up.
One of link-service-1 / link-service-2 exits on startup
Both create the ElasticSearch index at startup and allow five seconds for it.
Started together on a busy laptop, one can lose that race and exit. Start them
one at a time, or docker compose start link-service-1 again.
The application will not start: 5,000 milliseconds timeout on connection
It creates its ElasticSearch index at startup and allows five seconds. That
fails when ElasticSearch cannot allocate shards, which on a laptop means a
disk over 90% full — check curl localhost:9200/_cat/allocation?v. Free the
space, then delete the half-made index with
curl -X DELETE localhost:9200/linklab-links, or the next start finds it in
the same state.
./gradlew bootRun fails with IllegalArgumentException
Set Gradle JVM to Java 21 in IntelliJ Settings → Build Tools → Gradle → Gradle JVM.