[nextjs]SNS Server-30(myapp40)Β 

πŸ‘‰πŸ» νšŒμ›κ°€μž… 및 둜그인과 κ΄€λ ¨λœ κΈ°λŠ₯μž…λ‹ˆλ‹€.
These are features related to sign-up and login.

πŸ‘‰πŸ» myapp40μ—μ„œλŠ” κΈ°μ‘΄ 둜그인 λ³΄μ•ˆμ„ μ—…κ·Έλ ˆμ΄λ“œν•©λ‹ˆλ‹€.
myapp40 is upgrading its existing login security.

πŸ‘‰πŸ» κΈ°μ‘΄ access token외에 refresh token을 μΆ”κ°€ν•˜κ³  session ν…Œμ΄λΈ”μ— μ €μž₯ν•©λ‹ˆλ‹€.
In addition to the existing access token, a refresh token is added and stored in the session table.

πŸ‘‰πŸ» access token은 15λΆ„ refresh token은 7일둜 μ„€μ •ν•˜κ³  λ‘œκ·ΈμΈν•˜λ©΄ access tokenκ³Ό refresh token을 λ°œκΈ‰ν•˜κ³  session ν…Œμ΄λΈ”μ— μ €μž₯ν•©λ‹ˆλ‹€.

πŸ‘‰πŸ» λ‘œκ·ΈμΈν•œ μƒνƒœμ—μ„œ access token이 만료되면(15λΆ„) sessionν…Œμ΄λΈ”μ— refresh token(7일)이 μžˆλŠ”μ§€ ν™•μΈν•˜κ³  access token을 μž¬λ°œκΈ‰ν•©λ‹ˆλ‹€.
If the access token expires (after 15 minutes) while logged in, the system checks the session table for a refresh token (valid for 7 days) and issues a new access token.

πŸ‘‰πŸ» μžλ°”μŠ€ν¬λ¦½νŠΈμ—μ„œλŠ” μΏ ν‚€λ₯Ό 읽을 수 없도둝 μ„€μ •ν•˜κ³ (httpOnly: true) ν”„λ‘œλ•μ…˜λͺ¨λ“œμ—μ„œ https만 ν—ˆμš©ν•˜κ³ (secure: IS_PROD) λ‹€λ₯Έ μ‚¬μ΄νŠΈμ—μ„œ κΈ€μ“°κΈ°λ₯Ό λ§‰λŠ” 섀정을 μΆ”κ°€ν•©λ‹ˆλ‹€.(sameSite: ‘lax’)
In JavaScript, configure settings to prevent cookies from being read (httpOnly: true), allow only HTTPS in production mode (secure: IS_PROD), and add a setting to block write operations from other sites (sameSite: 'lax').

πŸ‘‰πŸ» 전체 μ½”λ“œλŠ” κΉƒν—ˆλΈŒμ—μ„œ 확인 ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
You can find the full code on GitHub.

https://github.com/gideonslife01/flm-nextjs

πŸ“ 전체 ν”„λ‘œμ νŠΈ ꡬ쑰 / Overall Project Structure

myapp project/  
β”œβ”€β”€ app/  (Next.js App Router)
β”‚   β”œβ”€β”€ .well-known/webfinger/route.ts  -> webfinger
β”‚   β”œβ”€β”€ api/follow/route.ts    -> Follow API(temporary)
β”‚   β”œβ”€β”€ api/announce/route.ts  -> Boost(Announcement)
β”‚   β”œβ”€β”€ api/like/route.ts.     -> Like API
β”‚   β”œβ”€β”€ api/posts/route.ts     -> Writing API
β”‚   β”œβ”€β”€ api/timeline/route.ts  -> Timeline API
β”‚   β”œβ”€β”€ api/v1/instance/route.ts -> auth
β”‚   β”œβ”€β”€ api/v1/apps/route.ts.    -> auth
β”‚   β”œβ”€β”€ api/v1/accounts/verify_credentials/route.ts -> auth
β”‚   β”œβ”€β”€ api/v1/statuses/route.ts -> Write Post
β”‚   β”œβ”€β”€ api/v1/timelines/home/route.ts -> timeline
β”‚   β”œβ”€β”€ oauth/authorize/route.ts -> auth
β”‚   β”œβ”€β”€ oauth/token/route.ts -> auth
β”‚   β”œβ”€β”€ api/v1/search/home/route.ts -> search
β”‚   β”œβ”€β”€ api/v2/search/home/route.ts -> search
β”‚   β”œβ”€β”€ api/v1/accounts/[id]/followers/route.ts -> followers
β”‚   β”œβ”€β”€ api/v1/accounts/[id]/following/route.ts -> following
β”‚   β”œβ”€β”€ api/auth/signup/route.ts        -> Signup
β”‚   β”œβ”€β”€ api/auth/login/route.ts         -> Login
β”‚   β”œβ”€β”€ api/auth/logout/route.ts        -> Logout
β”‚   β”œβ”€β”€ api/auth/refresh/route.ts       -> Refresh Token
β”‚   β”œβ”€β”€ api/auth/me/route.tsx           -> Login Check
β”‚   β”œβ”€β”€ auth/signup/page.tsx            -> Signup UI
β”‚   β”œβ”€β”€ auth/signup/page.tsx            -> Login UI
β”‚   β”œβ”€β”€ users/[username]/
β”‚   β”‚   β”œβ”€β”€ statuses/[id]/route.ts -> Indivisual Post
β”‚   β”‚   β”œβ”€β”€ route.ts               -> Acotr Information
β”‚   β”‚   β”œβ”€β”€ followers/route.ts     -> Followers List
β”‚   β”‚   β”œβ”€β”€ following/route.ts     -> Following List
β”‚   β”‚   β”œβ”€β”€ inbox/route.ts         -> Inbox
β”‚   β”‚   └── outbox/route.ts        -> outbox
β”‚   β”œβ”€β”€ usersui/[username]/
β”‚   β”‚   β”œβ”€β”€ page.tsx               -> Timeline UI
β”‚   β”‚   └── _components/themes/
β”‚   β”‚       β”œβ”€β”€ themeex/ThemeexTheme.tsx   -> Example Theme
β”‚   β”‚       β”œβ”€β”€ pinafore/PinaforeTheme.tsx -> Theme 1
β”‚   β”‚       β”œβ”€β”€ mastodon/MastodonTheme.tsx -> Theme 2
β”‚   β”‚       └── minimal/MinimaltTheme.tsx  -> Theme 3
β”‚   β”œβ”€β”€ layout.tsx, page.tsx, globals.css
β”‚   └── favicon.ico
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ theme.tsx              -> Theme Provider
β”‚   β”œβ”€β”€ watchThemes.ts         -> Check real-time theme changes
β”‚   β”œβ”€β”€ auth.ts                -> Authentication, User Management
β”‚   β”œβ”€β”€ ap.ts                  -> Follow,Undo,Create,Likes,Announce
β”‚   └── db.ts                  -> DB connection
β”œβ”€β”€ data/
β”‚   β”œβ”€β”€ keys/userIDs/          -> private.pem, public.pem(New)
β”‚   └── keys/                  -> private.pem, public.pem(legacy)
β”œβ”€β”€ data.sqlite                -> Database(1/3)
β”œβ”€β”€ data.sqlite-wal            -> Database(2/3)
β”œβ”€β”€ data.sqlite-shm            -> Database(3/3)
β”œβ”€β”€ Caddyfile                  -> https 
β”œβ”€β”€ instrumentation.ts         -> Background Server
└── package.json

πŸ“ ν”„λ‘œμ νŠΈ μ‹œμž‘ / Project Start


πŸ“ ν…Œμ΄λΈ” μΆ”κ°€ / Add Table

  CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    username TEXT NOT NULL,
    refresh_token TEXT NOT NULL UNIQUE,
    access_token TEXT,
    user_agent TEXT,
    ip TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    expires_at DATETIME NOT NULL,
    last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );

  CREATE INDEX IF NOT EXISTS idx_sessions_refresh ON sessions(refresh_token);
  CREATE INDEX IF NOT EXISTS idx_sessions_username ON sessions(username);
  CREATE INDEX idx_sessions_expires ON sessions(expires_at);

πŸ“ sessionν…Œμ΄λΈ” ν•„λ“œ μ„€λͺ… / Description of session table fields

— κΈ°μ‘΄ oauth_tokensν…Œμ΄λΈ”μ€ pinaforeλ“± μ™ΈλΆ€ μ•±μš©
The existing oauth_tokens table is for external apps such as pinafore.

— sessionν…Œμ΄λΈ”μ€ 둜컬 μ „μš©
The session table is local-only

컬럼 / Columnκ°’ μ˜ˆμ‹œ / Value Exampleμ„€λͺ… / Explanation
ida1b2c3d4-...μ„Έμ…˜ 고유ID
Session Unique ID
usernameuser1λ‘œκ·ΈμΈν•œ μ‚¬λžŒ
Logged-in user
refresh_token9f8e7d... (128κΈ€μž!)7일짜리 토큰- DBμ—μ„œ 직접 ν™•μΈμš©
7-day token – for direct verification in the DB
access_tokeneyJ... (JWT)15λΆ„μ§œλ¦¬ 토큰 – μ§€κΈˆ μ‚¬μš©ν•˜κ³  μžˆλŠ” 토큰
15-minute token – the token currently in use
user_agentMozilla/5.0... Chromeν¬λ‘¬μ—μ„œ λ‘œκ·ΈμΈν–ˆλŠ”μ§€ Pinafore!μ—μ„œ!ν–ˆλŠ”μ§€ ν™•μΈμš©
To check if you logged in on Chrome or on Pinafore!
ip1.2.3.4μ–΄λ””μ„œ 둜그인 ν–ˆλŠ”μ§€ (ν•΄ν‚Ή ν™•μΈμš©)
Where the login occurred (to check for hacking)
expires_at2026-09-22 10:00:00만료 μ‹œκ°„μ€ 7일 ν›„
The expiration time is 7 days later

πŸ“ μ½”λ“œ μˆ˜μ • / Code Modification

βœ”οΈ lib/db.ts

— λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™” / Initialize Database

db.exec(`
... ...
  -- myapp40 βœ… Session
    CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    username TEXT NOT NULL,
    refresh_token TEXT NOT NULL UNIQUE,
    access_token TEXT,
    user_agent TEXT,
    ip TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    expires_at DATETIME NOT NULL,
    last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );

`);

db.exec(`
... ...
  -- session -- βœ… myapp40
  CREATE INDEX IF NOT EXISTS idx_sessions_refresh ON sessions(refresh_token);
  CREATE INDEX IF NOT EXISTS idx_sessions_username ON sessions(username);
  CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
`);

βœ”οΈ lib/auth.ts

// βœ… myapp40 - 15λΆ„μ§œλ¦¬ Access Token / 15-minute access token
export function createAccessToken(username: string) {
  return jwt.sign({ username, type: 'access' }, JWT_SECRET, { expiresIn: '15m' });
}

// βœ… myapp40 - 7일짜리 Refresh Token (랜덀 λ¬Έμžμ—΄ + DB μ €μž₯용)
// 7-day Refresh Token (random string + for DB storage)
export function createRefreshToken() {
  return crypto.randomBytes(64).toString('hex'); // 128κΈ€μž 랜덀 / 128-character random string
}

// - κΈ°μ‘΄ ν˜Έν™˜μš© (Pinafore OAuthλŠ” 아직 7일짜리 써도 됨)
export function createToken(username: string) {
  return jwt.sign({ username }, JWT_SECRET, { expiresIn: '7d' });
}

... ...

// βœ… myapp40 - sessions ν…Œμ΄λΈ”μ— μ €μž₯ / Save to the sessions table.
export function createSession(username: string, userAgent: string | null, ip: string | null) {
  const id = crypto.randomUUID();
  const refreshToken = createRefreshToken();
  const accessToken = createAccessToken(username);

  db.prepare(`
    INSERT INTO sessions (id, username, refresh_token, access_token, user_agent, ip, expires_at)
    VALUES (?,?,?,?,?,?, datetime('now', '+7 days'))
  `).run(id, username, refreshToken, accessToken, userAgent, ip);

  return { id, refreshToken, accessToken };
}

βœ”οΈ app/api/auth/login/route.ts

— 이 λΆ€λΆ„μ—μ„œ λ³΄μ•ˆμ„€μ • / Security settings in this section
Security settings in this section

    // βœ… Access Token - 15λΆ„ - λͺ¨λ“  κ²½λ‘œμ—μ„œ μ‚¬μš© / Use for all routes
    res.cookies.set('token', accessToken, {
      httpOnly: true, // βœ… true - JSμ—μ„œ λͺ» 읽음 XSS λ°©μ–΄ / XSS protection: Unreadable by JS
      secure: IS_PROD, // only https
      sameSite: 'lax', // λ‹€λ₯Έ μ‚¬μ΄νŠΈμ—μ„œ POSTμš”μ²­μ‹œ μΏ ν‚€ μ•ˆλ³΄λƒ„ / Cookies are not sent during POST requests from other sites.
      maxAge: 60*15, // 15λΆ„ / 15minute
      path: '/'
    });

    // βœ… Refresh Token - 7일! - /api/auth/refresh μ—μ„œλ§Œ μ‚¬μš©
    res.cookies.set('refresh_token', refreshToken, {
      httpOnly: true,
      secure: IS_PROD,
      sameSite: 'lax',
      maxAge: 60*60*24*7, // 7일 / 7days
      path: '/'
    });

βœ”οΈ app/api/auth/refresh/route.ts – 라우트 μΆ”κ°€ / Add route

export async function POST(req: Request) {
... ...
  if (!result) {
    const res = NextResponse.json({ error: 'refresh_token 만료 λ‹€μ‹œ 둜그인 / Refresh token expired; please log in again.' }, { status: 401 });
    res.cookies.set('token', '', { maxAge: 0, path: '/' });
    res.cookies.set('refresh_token', '', { maxAge: 0, path: '/' });
    return res;
  }
... ...
}

βœ”οΈ app/api/auth/logout/route.ts

export async function POST(req: Request) {
... ...
  // oauth_tokens도 μ‚­μ œ (Pinafore 토큰)
  // Delete oauth_tokens as well (Pinafore tokens)
  const tokenMatch = cookie.match(/token=([^;]+)/);
  if (tokenMatch) {
    try {
      const db = (await import('@/lib/db')).default;
      db.prepare('DELETE FROM oauth_tokens WHERE access_token =?').run(decodeURIComponent(tokenMatch[1]));
    } catch {}
  }
... ...
}

βœ”οΈ app/auth/login/page.tsx

— 둜그인 적용 / Apply login

— useEffectμ—μ„œ checkLogin으둜 둜그인 확인
Check login status using checkLogin within useEffect.

— λ‘œκ·ΈμΈλ˜μ–΄ 있으면 currentUser μ…‹νŒ…
Set currentUser if logged in.

— currentUserλ³€μˆ˜ κ°’μ˜ 쑴재 여뢀에 따라 보여쀄 νŽ˜μ΄μ§€ μ„€μ •
Configure the page to be displayed based on the presence of the currentUser variable.

'use client';
import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';

function LoginForm() {
  const [loginId, setLoginId] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [currentUser, setCurrentUser] = useState('');
  const [loading, setLoading] = useState(true);
  const router = useRouter();
  const searchParams = useSearchParams();
  const next = searchParams.get('next') || '/';

  useEffect(() => {
    async function checkLogin() {
      try {
        const res = await fetch('/api/auth/me', { credentials: 'include' });
        if (res.ok) {
          const data = await res.json();
          if (data.username) {
            setCurrentUser(data.username);
          }
        }
      } catch {}
      setLoading(false);
    }
    checkLogin();
  }, []);

... ... 

 return (
    <div style={{ maxWidth: 400, margin: '80px auto', padding: 20 }}>
      <h1>πŸ” 둜그인 / Login {currentUser ? `${currentUser} - ` : ''} myapp40</h1>

      {currentUser ? (
        <>
        <p style={{ color: 'green', fontSize: 18 }}>ν˜„μž¬ 둜그인: <strong>{currentUser}</strong></p>

      ) : (

        <form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 20 }}> 

... ...

       </form>
      )}
... ...
}

βœ”οΈ app/api/auth/me/route.tsx – 라우트 μΆ”κ°€ / Add route

// βœ… myapp40 -  app/api/auth/me/route.ts
import { NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';

export async function GET(req: Request) {
  const cookie = req.headers.get('cookie') || '';
  const m = cookie.match(/token=([^;]+)/);

  if (!m) {
    return NextResponse.json({ error: 'Not logged in' }, { status: 401 });
  }

  const token = decodeURIComponent(m[1]);
  const decoded = verifyToken(token);

  if (!decoded?.username) {
    return NextResponse.json({ error: 'Token expired' }, { status: 401 });
  }

  return NextResponse.json({ ok: true, username: decoded.username });
}

βœ”οΈ middleware.ts

— λ‘œκ·ΈμΈν•˜μ§€μ•ŠμœΌλ©΄ νŽ˜μ΄μ§€ 차단기λŠ₯ μ‚¬μš©ν•  경우
If you use the page blocking function without logging in…

export function middleware(req: NextRequest) {
  const origin = req.headers.get('origin') || '';
  const path = req.nextUrl.pathname;
  const DOMAIN = process.env.DOMAIN;

  // βœ… ν—ˆμš©ν•  origin λͺ©λ‘!
  const allowedOrigins = [
    `https://${DOMAIN}`,
    `https://pinafore.social`,
    `http://localhost:3000`,
    `http://localhost:3001`
  ];
  const isAllowedOrigin = allowedOrigins.includes(origin) ||!origin;

  // // ===== 1. 둜그인 체크 (νŽ˜μ΄μ§€ 보호) / Login check (page protection) =====

  // const token = req.cookies.get('token')?.value;
  // const refreshToken = req.cookies.get('refresh_token')?.value;

  // 둜그인이 ν•„μš”ν•œ νŽ˜μ΄μ§€ / Page requiring login
  // const protectedPaths = ['/admin', '/settings', '/oauth/authorize'];
  // const isProtected = protectedPaths.some(p => path.startsWith(p));

  // if (isProtected &&!token &&!refreshToken) {
  //   // 토큰 μ—†μœΌλ©΄ 둜그인 νŽ˜μ΄μ§€λ‘œ / Redirect to the login page if there is no token.
  //   const loginUrl = new URL('/auth/login', req.url);
  //   loginUrl.searchParams.set('next', path);
  //   return NextResponse.redirect(loginUrl);
  // }

... }

πŸ“ ν…ŒμŠ€νŠΈ / Test

βœ”οΈ μΏ ν‚€ μ €μž₯ 및 λ³΄μ•ˆμ„€μ • μƒνƒœ
Cookie storage and security settings status

— https://aloy-horizon.duckdns.org/auth/login 이 λΌμš°νŠΈμ—μ„œ 둜그인이 μ™„λ£Œλ˜λ©΄ 쿠킀와 λ‘œμ»¬μŠ€ν† λ¦¬μ§€μ— μ €μž₯된 토큰 및 아이디λ₯Ό 확인 ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
Once login is completed at the route https://aloy-horizon.duckdns.org/auth/login, you can verify the tokens and IDs stored in cookies and local storage.

— cmd+option+I(macOS)둜 확인 ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
You can check it using Cmd+Option+I (macOS).

Cookie
local storage

βœ”οΈ μ„œλ²„λ‘œκ·Έ / Server Log

— μ—‘μ„ΈμŠ€ 토큰 λ§Œλ£Œμ‹œ μƒˆλ‘œμš΄ μ—‘μ„ΈμŠ€ 토큰을 λ°œκΈ‰ν•©λ‹ˆλ‹€.
A new access token is issued when the access token expires.

Server Log

μš”ν•œλ³΅μŒ 8μž₯ 32절 / John 8:32

“그리고 λ„ˆν¬λŠ” 진리λ₯Ό μ•Œκ²Œ 될 것이며, 진리가 λ„ˆν¬λ₯Ό 자유둭게 ν•  것이닀.”

“Then you will know the truth ,and the truth will set you free”

Leave a Reply