Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80314f22d0 | ||
|
|
c9c414d83f | ||
|
|
27c7455870 | ||
|
|
42d5eb286f | ||
|
|
25b04dd86b | ||
|
|
62e2f5536d | ||
|
|
1111f961dd | ||
|
|
95e5336e50 |
@@ -1,4 +1,4 @@
|
||||
name: Deploy to prod
|
||||
name: Main CD-Deploy to Prod
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
required: false
|
||||
default: "true"
|
||||
workflow_run:
|
||||
workflows: [ "Backend Main", "Frontend Main" ]
|
||||
workflows: [ "Main CI-Backend", "Main CI-Frontend" ]
|
||||
types: [ completed ]
|
||||
|
||||
jobs:
|
||||
@@ -135,6 +135,7 @@ jobs:
|
||||
&& needs.terraform-plan.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: production
|
||||
url: https://tasknote.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -1,14 +1,15 @@
|
||||
name: Deploy to staging
|
||||
name: Pull Request CD-Deploy to Staging
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: [ "Backend PR", "Frontend PR" ]
|
||||
workflows: [ "Pull Request CI-Backend", "Pull Request CI-Frontend" ]
|
||||
types: [ completed ]
|
||||
|
||||
jobs:
|
||||
terraform-plan-stg:
|
||||
name: Plan changs to staging
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
@@ -99,6 +100,7 @@ jobs:
|
||||
if: needs.terraform-plan-stg.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: staging
|
||||
url: https://tasknote-stg.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Backend Main
|
||||
name: Main CI-Backend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -69,18 +69,29 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image with Spring Boot
|
||||
working-directory: ./server
|
||||
run: |
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Tag and push Docker image
|
||||
- name: Find PR number
|
||||
id: find_pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.version.outputs.version }}
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:latest
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.version.outputs.version }}
|
||||
PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No merged PR found for this commit. Falling back to 'candidate' tag."
|
||||
PR_NUMBER="candidate"
|
||||
else
|
||||
PR_NUMBER="pr-${PR_NUMBER}"
|
||||
fi
|
||||
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Promote Docker image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.version.outputs.version }} \
|
||||
ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.find_pr.outputs.tag }}
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Frontend Main
|
||||
name: Main CI-Frontend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -31,6 +31,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set lowercase repo name
|
||||
id: repo
|
||||
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -49,26 +53,26 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/app
|
||||
tags: |
|
||||
type=raw,value=${{ steps.version.outputs.tag }}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
- name: Find PR number
|
||||
id: find_pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No merged PR found for this commit. Falling back to 'candidate' tag."
|
||||
PR_NUMBER="candidate"
|
||||
else
|
||||
PR_NUMBER="pr-${PR_NUMBER}"
|
||||
fi
|
||||
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
VITE_BUILD=v${{ steps.version.outputs.tag }}
|
||||
- name: Promote Docker image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/app:latest \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/app:${{ steps.version.outputs.tag }} \
|
||||
ghcr.io/${{ steps.repo.outputs.name }}/app:${{ steps.find_pr.outputs.tag }}
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Backend PR
|
||||
name: Pull Request CI-Backend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -51,6 +51,7 @@ jobs:
|
||||
needs: ["run-checks"]
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
@@ -91,10 +92,38 @@ jobs:
|
||||
working-directory: ./server
|
||||
run: |
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=ghcr.io/rmcampos/tasknote/api:latest \
|
||||
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
|
||||
- name: Tag and push Docker image
|
||||
run: |
|
||||
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
|
||||
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Create GitHub deployment for staging
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const ref = context.payload.pull_request.head.sha;
|
||||
const env = 'staging';
|
||||
const resp = await github.rest.repos.createDeployment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref,
|
||||
required_contexts: [],
|
||||
environment: env,
|
||||
description: `PR #${context.payload.pull_request.number} preview deployment`,
|
||||
transient_environment: true,
|
||||
auto_merge: false
|
||||
});
|
||||
// create a deployment status pointing to the staging URL
|
||||
await github.rest.repos.createDeploymentStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
deployment_id: resp.data.id,
|
||||
state: 'success',
|
||||
environment_url: 'https://tasknote-stg.darkroasted.vps-kinghost.net'
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Frontend PR
|
||||
name: Pull Request CI-Frontend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -60,6 +60,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -84,6 +85,7 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository }}/app
|
||||
tags: |
|
||||
type=raw,value=candidate
|
||||
type=raw,value=pr-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
@@ -104,3 +106,29 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
VITE_BUILD=${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Create GitHub deployment for staging
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const ref = context.payload.pull_request.head.sha;
|
||||
const env = 'staging';
|
||||
const resp = await github.rest.repos.createDeployment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref,
|
||||
required_contexts: [],
|
||||
environment: env,
|
||||
description: `PR #${context.payload.pull_request.number} preview deployment`,
|
||||
transient_environment: true,
|
||||
auto_merge: false
|
||||
});
|
||||
// create a deployment status pointing to the staging URL
|
||||
await github.rest.repos.createDeploymentStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
deployment_id: resp.data.id,
|
||||
state: 'success',
|
||||
environment_url: 'https://tasknote-stg.darkroasted.vps-kinghost.net'
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isSafeUrl } from '../../utils/UrlUtils';
|
||||
|
||||
describe('UrlUtils', () => {
|
||||
it('should allow http:// URLs', () => {
|
||||
expect(isSafeUrl('http://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow https:// URLs', () => {
|
||||
expect(isSafeUrl('https://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow # URLs', () => {
|
||||
expect(isSafeUrl('#section')).toBe(true);
|
||||
});
|
||||
|
||||
it('should disallow javascript: URLs', () => {
|
||||
expect(isSafeUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('should disallow data: URLs', () => {
|
||||
expect(isSafeUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
|
||||
});
|
||||
|
||||
it('should disallow empty or null URLs', () => {
|
||||
expect(isSafeUrl('')).toBe(false);
|
||||
expect(isSafeUrl(null)).toBe(false);
|
||||
expect(isSafeUrl(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case insensitive for protocol', () => {
|
||||
expect(isSafeUrl('HTTP://example.com')).toBe(true);
|
||||
expect(isSafeUrl('HTTPS://example.com')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { env } from '../env';
|
||||
|
||||
const server = env.VITE_BACKEND_SERVER;
|
||||
const server = env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const ApiConfig = {
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import ExternalLinkIcon from '../../assets/icons8-external-link-30.png';
|
||||
import { isSafeUrl } from '../../utils/UrlUtils';
|
||||
|
||||
interface Props {
|
||||
readonly title: string;
|
||||
@@ -18,8 +19,8 @@ function NoteTitle(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
<span className="task-title-icon">
|
||||
<span className="poppins-semibold">
|
||||
{props.title}
|
||||
{props.noteUrl && props.noteUrl.length > 0 && (
|
||||
<a href={props.noteUrl} target="_blank" rel="noreferrer" className="task-note-external-link">
|
||||
{isSafeUrl(props.noteUrl) && (
|
||||
<a href={props.noteUrl!} target="_blank" rel="noreferrer" className="task-note-external-link">
|
||||
<img src={ExternalLinkIcon} width={20} alt="external link" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import ExternalLinkIcon from '../../assets/icons8-external-link-30.png';
|
||||
import { isSafeUrl } from '../../utils/UrlUtils';
|
||||
import './style.css';
|
||||
|
||||
interface Props {
|
||||
@@ -24,7 +25,7 @@ function TaskTitle(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
data-testid={`task-title-text-${props.title}`}
|
||||
>
|
||||
{props.title}
|
||||
{props.taskUrl && props.taskUrl.length > 0 && (
|
||||
{props.taskUrl && props.taskUrl.length > 0 && isSafeUrl(props.taskUrl[0]) && (
|
||||
<a href={props.taskUrl[0]} target="_blank" rel="noreferrer" className="task-note-external-link">
|
||||
<img src={ExternalLinkIcon} width={20} alt="external link" />
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Validates if a URL is safe to be used in an <a> tag.
|
||||
* Only allows http, https, and # (for internal links/placeholders).
|
||||
*
|
||||
* @param {string | null | undefined} url The URL to validate.
|
||||
* @returns {boolean} True if the URL is safe, false otherwise.
|
||||
*/
|
||||
export function isSafeUrl(url: string | null | undefined): boolean {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
const safeProtocolRegex = /^(https?:\/\/|#)/i;
|
||||
return safeProtocolRegex.test(url);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm';
|
||||
import { NoteResponse } from '../../types/NoteResponse';
|
||||
import api from '../../api-service/api';
|
||||
import ApiConfig from '../../api-service/apiConfig';
|
||||
import { isSafeUrl } from '../../utils/UrlUtils';
|
||||
|
||||
/**
|
||||
* SharedNote component for displaying a publicly shared note.
|
||||
@@ -80,9 +81,9 @@ function SharedNote(): React.ReactNode {
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Card.Title>{note.title}</Card.Title>
|
||||
{note.url && (
|
||||
{isSafeUrl(note.url) && (
|
||||
<p>
|
||||
<a href={note.url} target="_blank" rel="noopener noreferrer">
|
||||
<a href={note.url!} target="_blank" rel="noopener noreferrer">
|
||||
{note.url}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -11,8 +11,6 @@ services:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
ports: ["5000:5000"]
|
||||
environment:
|
||||
VITE_BACKEND_SERVER: http://localhost:8585
|
||||
networks:
|
||||
- tasknote-network
|
||||
|
||||
@@ -27,7 +25,7 @@ services:
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
POSTGRES_PORT: 5432
|
||||
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000
|
||||
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000, https://flattop-depth-dropper.ngrok-free.dev
|
||||
SERVER_SERVLET_CONTEXT_PATH: /
|
||||
TARGET_ENV: development
|
||||
SECURITY_KEY: this-is-a-very-long-security-key-for-dev
|
||||
@@ -62,4 +60,4 @@ services:
|
||||
|
||||
networks:
|
||||
tasknote-network:
|
||||
driver: bridge
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
events {}
|
||||
http {
|
||||
server {
|
||||
listen 8181;
|
||||
server_name _;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://tasknote-api:8585/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://tasknote-web:5000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
ngrok http 8181 --log=stdout > ngrok-8181.log 2>&1 &
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
docker run -d \
|
||||
--name ngrok-tasknote-proxy \
|
||||
-p 127.0.0.1:8181:8181 \
|
||||
-v ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro \
|
||||
--restart unless-stopped \
|
||||
--network tasknote-network \
|
||||
nginx:stable
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
<groupId>br.com.tasknoteapp</groupId>
|
||||
<artifactId>server</artifactId>
|
||||
<version>17</version>
|
||||
<version>19</version>
|
||||
<name>tasknote-api</name>
|
||||
<description>Java backend REST API to serve TaskNote frontend client</description>
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ public class UserEntity implements UserDetails {
|
||||
@Column(name = "lang", nullable = true, length = 6)
|
||||
private String lang;
|
||||
|
||||
@Column(name = "last_password_change", nullable = false)
|
||||
private LocalDateTime lastPasswordChange;
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of();
|
||||
@@ -182,4 +185,12 @@ public class UserEntity implements UserDetails {
|
||||
public void setLang(String lang) {
|
||||
this.lang = lang;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastPasswordChange() {
|
||||
return lastPasswordChange;
|
||||
}
|
||||
|
||||
public void setLastPasswordChange(LocalDateTime lastPasswordChange) {
|
||||
this.lastPasswordChange = lastPasswordChange;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
/** This record represents a note patch payload. */
|
||||
public record NotePatchRequest(String title, String description, String url, String tag) {}
|
||||
public record NotePatchRequest(
|
||||
String title,
|
||||
String description,
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
String url,
|
||||
String tag) {}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
/** This record represents a note request to be created. */
|
||||
public record NoteRequest(
|
||||
@NotNull String title, @NotNull String description, String url, String tag) {}
|
||||
@NotNull String title,
|
||||
@NotNull String description,
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
String url,
|
||||
String tag) {}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task patch payload. */
|
||||
public record TaskPatchRequest(
|
||||
String description,
|
||||
Boolean done,
|
||||
List<String> urls,
|
||||
List<
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
String>
|
||||
urls,
|
||||
String dueDate,
|
||||
Boolean highPriority,
|
||||
String tag) {}
|
||||
|
||||
@@ -2,12 +2,18 @@ package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task request to be created. */
|
||||
public record TaskRequest(
|
||||
@NotNull @NotEmpty String description,
|
||||
List<String> urls,
|
||||
List<
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
String>
|
||||
urls,
|
||||
String dueDate,
|
||||
Boolean highPriority,
|
||||
String tag) {}
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
@@ -131,7 +132,8 @@ public class AuthService {
|
||||
user.setEmail(newUser.email());
|
||||
user.setPassword(passwordEncoder.encode(newUser.password()));
|
||||
user.setAdmin(false);
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
user.setCreatedAt(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
user.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
user.setEmailUuid(emailUuid);
|
||||
user.setLang(newUser.lang());
|
||||
userRepository.save(user);
|
||||
@@ -332,6 +334,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
currentUser.setPassword(passwordEncoder.encode(patchRequest.password()));
|
||||
currentUser.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
shouldUpdate = true;
|
||||
}
|
||||
|
||||
@@ -433,7 +436,8 @@ public class AuthService {
|
||||
|
||||
UserEntity user = userOptional.get();
|
||||
user.setResetToken(resetToken);
|
||||
user.setResetPasswordExpiration(LocalDateTime.now().plusHours(2L));
|
||||
user.setResetPasswordExpiration(
|
||||
LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS).plusHours(2L));
|
||||
|
||||
userRepository.save(user);
|
||||
if (hasValidMailgunApiKey()) {
|
||||
@@ -477,6 +481,7 @@ public class AuthService {
|
||||
user.setResetToken(null);
|
||||
user.setResetPasswordExpiration(null);
|
||||
user.setPassword(passwordEncoder.encode(request.password()));
|
||||
user.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
|
||||
userRepository.save(user);
|
||||
if (hasValidMailgunApiKey()) {
|
||||
|
||||
@@ -51,6 +51,14 @@ class JwtServiceImpl implements JwtService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private LocalDateTime extractIssuedAt(String token) {
|
||||
Date date = extractClaim(token, Claims::getIssuedAt);
|
||||
if (!Objects.isNull(date)) {
|
||||
return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateToken(UserEntity user) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
@@ -91,7 +99,18 @@ class JwtServiceImpl implements JwtService {
|
||||
@Override
|
||||
public boolean validateTokenAndUser(String token, UserDetails user) {
|
||||
final String email = user.getUsername();
|
||||
return !isTokenExpired(token) && email.equals(getEmailFromToken(token));
|
||||
boolean basicValid = !isTokenExpired(token) && email.equals(getEmailFromToken(token));
|
||||
|
||||
if (basicValid && user instanceof UserEntity userEntity) {
|
||||
LocalDateTime iat = extractIssuedAt(token);
|
||||
if (iat != null && userEntity.getLastPasswordChange() != null) {
|
||||
// Token must be issued after or at the same time as last password change
|
||||
// We use isBefore to invalidate tokens issued BEFORE the change
|
||||
return !iat.isBefore(userEntity.getLastPasswordChange());
|
||||
}
|
||||
}
|
||||
|
||||
return basicValid;
|
||||
}
|
||||
|
||||
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE tasknote.users ADD COLUMN last_password_change TIMESTAMP WITHOUT TIME ZONE;
|
||||
|
||||
-- Initialize for existing users
|
||||
UPDATE tasknote.users SET last_password_change = created_at WHERE last_password_change IS NULL;
|
||||
|
||||
ALTER TABLE tasknote.users ALTER COLUMN last_password_change SET NOT NULL;
|
||||
@@ -276,7 +276,7 @@ class TaskControllerTest {
|
||||
"""
|
||||
{
|
||||
"description": "Test task",
|
||||
"urls": ["www.url.com"],
|
||||
"urls": ["https://www.url.com"],
|
||||
"highPriority": true,
|
||||
"tag": "tag"
|
||||
}
|
||||
|
||||
@@ -166,6 +166,30 @@ class JwtServiceImplTest {
|
||||
assertFalse(valid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenAndUser_shouldReturnFalseIfTokenIssuedBeforeLastPasswordChange()
|
||||
throws InterruptedException {
|
||||
UserEntity user = new UserEntity();
|
||||
user.setId(testUserId);
|
||||
user.setEmail(testEmail);
|
||||
user.setAdmin(false);
|
||||
user.setName(testName);
|
||||
user.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
|
||||
// Token issued NOW
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
// Update lastPasswordChange to FUTURE (simulating a password change after token issuance)
|
||||
// We wait 1 second to ensure the new timestamp is strictly after token iat (which has second
|
||||
// precision)
|
||||
Thread.sleep(1100);
|
||||
user.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
|
||||
boolean valid = jwtService.validateTokenAndUser(token, user);
|
||||
|
||||
assertFalse(valid, "Token issued before password change should be invalid");
|
||||
}
|
||||
|
||||
private Claims extractClaims(String token) {
|
||||
return Jwts.parser().verifyWith(getKey()).build().parseSignedClaims(token).getPayload();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Create test user
|
||||
insert into users (email, password, admin, created_at, inactivated_at)
|
||||
select 'test@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null
|
||||
insert into users (email, password, admin, created_at, inactivated_at, last_password_change)
|
||||
select 'test@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null, current_timestamp
|
||||
where not exists (select 1 from users where email = 'test@domain.com');
|
||||
|
||||
-- Create some tasks
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Create test user
|
||||
insert into users (email, password, admin, created_at, inactivated_at)
|
||||
select 'test@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null
|
||||
insert into users (email, password, admin, created_at, inactivated_at, last_password_change)
|
||||
select 'test@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null, current_timestamp
|
||||
where not exists (select 1 from users where email = 'test@domain.com');
|
||||
|
||||
-- Create a task
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Create test user
|
||||
insert into users (email, password, admin, created_at, inactivated_at, email_uuid, reset_token)
|
||||
select 'testuuid@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null, 'cc2b5506-83ed-5764-985e-611ad4ce8050', 'abc123456'
|
||||
insert into users (email, password, admin, created_at, inactivated_at, email_uuid, reset_token, last_password_change)
|
||||
select 'testuuid@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null, 'cc2b5506-83ed-5764-985e-611ad4ce8050', 'abc123456', current_timestamp
|
||||
where not exists (select 1 from users where email = 'testuuid@domain.com');
|
||||
|
||||
Reference in New Issue
Block a user