> 웹 프론트엔드 > JS 튜토리얼 > Arcjet으로 개인 금융 앱 구축

Arcjet으로 개인 금융 앱 구축

WBOY
풀어 주다: 2024-09-07 00:01:32
원래의
1155명이 탐색했습니다.

요즘 우리는 애플리케이션과 플랫폼이 제3자의 공격으로부터 보호하기 위해 보안 조치를 취해야 하는 보안 중심 환경에 살고 있습니다. 이러한 기능을 제공하는 플랫폼이 꽤 많이 있습니다. Arcjet에서 저에게 연락하여 베타 버전을 사용해 보고 경험에 대해 글을 쓰고 싶은지 물었습니다. 그들은 내 시간에 대한 대가를 지불했지만 이 글에 영향을 미치지는 않았습니다.

이 기사는 Arcjet, Next.js, Auth.js, Prisma, SQLite 및 Tailwind CSS를 사용하여 간단한 재무 관리 앱을 만드는 방법에 대한 가이드 역할을 합니다. 최신 웹 개발 기술, 실용적인 기능, 강력한 보안을 갖춘 개인 재무 관리 앱을 구축하면 이러한 기능을 통합하는 것이 얼마나 효과적인지 깨닫게 됩니다. 우리의 애플리케이션은 개념 증명이므로 실시간 결제 게이트웨이는 없습니다.

아크젯이란 무엇인가요?

Arcjet은 애플리케이션을 보호하는 데 필요한 필수 기능을 갖춘 플랫폼입니다. 플랫폼에는 SQL 주입, XSS 공격, CSRF 등의 공격을 방지할 수 있는 Arcjet Shield 등 많은 기능이 있습니다. Arcjet Shield는 금융 시스템에서 발견된 개인 정보에 대한 무단 접근을 방지할 수 있습니다. 또한 데이터베이스에서 트랜잭션과 같은 작업이 발생하는 빈도를 제어하는 ​​속도 제한이 있어 결과적으로 불필요한 악용이 발생하는 것을 방지합니다.

봇 보호는 자동화된 시스템을 사용하여 정보를 훔치거나 사기를 저지르는 악성 봇으로부터 앱을 보호합니다. 이메일 검증은 제공된 모든 이메일 주소가 유효한지 확인하므로 실제 사용자를 유지하는 데 도움이 됩니다. 또한, 가입 양식을 보호하여 사기 등록 시도 및 무단 로그인을 방지하는 도구를 제공합니다.

이 튜토리얼을 마치면 간단하게 작동하는 개인 금융 앱을 만들 수 있으므로 시작해 보세요!

프로젝트 구조 설정

코드베이스 작업을 시작하기 전에 먼저 프로젝트의 아키텍처를 설정해야 합니다. 우리 애플리케이션의 홈페이지는 다음과 같습니다:

Building a Personal Finance App with Arcjet

우리 애플리케이션은 TypeScript를 사용하여 구축되며 기술 스택은 다음과 같습니다.

  • 아크젯
  • Next.js
  • Auth.js
  • 프리즈마
  • SQLite
  • 테일윈드 CSS

Arcjet에서 무료 계정을 생성하고 SDK 구성 섹션에서 찾을 수 있는 Arcjet API 키를 기록해 두어야 합니다. 앱에서 Arcjet 플랫폼을 사용하여 모든 보안 기능에 액세스하려면 API 키가 필요합니다.

여기에서 온라인으로 코드베이스를 찾을 수 있습니다 https://github.com/andrewbaisden/personal-finance-app
프로젝트 파일과 폴더부터 시작해 보겠습니다. 데스크탑과 같이 프로젝트를 생성하려는 컴퓨터의 디렉터리로 이동합니다. 이제 명령줄에서 다음 명령을 실행하여 TypeScript를 사용하는 상용구 Next.js 프로젝트를 스캐폴드하세요.

npx create-next-app@latest personal-finance-app --ts
로그인 후 복사

이 명령은 personal-finance-app이라는 프로젝트 폴더를 생성합니다. Tailwind CSS에 대해 예를 선택했는지 확인하세요. 스타일 지정에 해당 CSS 라이브러리를 사용할 것이기 때문입니다. 페이지 라우팅에 사용할 앱 라우터에 대해 예를 선택하세요.

자, 이제 이 프로젝트에 필요한 모든 패키지를 설치한 다음 프로젝트 구조 작업을 진행하겠습니다. 먼저 personal-finance-app 폴더로 CD를 이동하고 다음 명령을 실행하여 프로젝트의 모든 패키지와 종속성을 설치합니다.

npm i @arcjet/next @prisma/client arcjet axios bcryptjs jsonwebtoken next-auth prisma sqlite3 @types/bcryptjs @types/jsonwebtoken
로그인 후 복사

다음으로 다음 명령을 실행하여 모든 파일과 폴더를 설정하세요. 지루할 수 있는 모든 파일과 폴더를 직접 수동으로 생성할 필요가 없기 때문에 스크립트를 사용하여 이 방법으로 프로젝트를 설정하는 것이 훨씬 더 빠릅니다.

touch .env.local
cd src/app
mkdir -p api
mkdir -p api/auth
mkdir -p api/auth/"[...nextauth]"
mkdir -p api/{generatejwt,signin,signup,transaction,user}
touch api/auth/"[...nextauth]"/route.ts api/generatejwt/route.ts api/signin/route.ts api/signup/route.ts api/transaction/route.ts api/user/route.ts
mkdir -p components signin signup transaction
touch components/Header.tsx signin/page.tsx signup/page.tsx  transaction/page.tsx middleware.ts
cd ../..
로그인 후 복사

좋아요. 대부분의 작업이 완료되었습니다. 아직 열지 않았다면 지금 코드 편집기에서 코드베이스를 엽니다. 다음 섹션에서는 데이터베이스를 설정한 다음 코드베이스 생성을 시작할 수 있습니다.

백엔드 구성

저희 개인 금융 애플리케이션에는 사용자를 저장하기 위한 SQLite 데이터베이스가 있습니다. 사용자는 가입 양식을 사용하여 계정을 만든 다음 계정에 로그인할 수 있으며 이름, 이메일 주소, 은행 잔고와 같은 사용자 정보가 표시됩니다.

Prisma를 사용하여 애플리케이션을 데이터베이스에 연결하겠습니다. Prisma는 ORM(객체 관계형 매퍼)이며 데이터베이스와 객체 지향 프로그래밍 간의 데이터 표현을 변환하도록 특별히 설계되었습니다.

우리 프로젝트에서 Prisma를 설정하려면 다음 명령을 실행하세요.

npx prisma init
로그인 후 복사

이 명령은 기본적으로 우리 프로젝트에서 Prisma를 초기화합니다. 이제 prisma/schema.prisma에 파일이 생겼습니다. 해당 파일 내부의 코드를 데이터 모양을 정의하는 다음 데이터베이스 스키마로 바꾸세요.

datasource db {
    provider = "sqlite"
    url = "file:./dev.db"
}

generator client {
    provider = "prisma-client-js"
}

model User {
    id Int @id @default(autoincrement())
    name String
    email String @unique
    accountNumber String @unique
    balance Float @default(0)
    password String
}
로그인 후 복사

Now run these commands to generate a Prisma client and apply the database migrations so that everything gets in sync:

npx prisma generate
npx prisma migrate dev --name init
로그인 후 복사

We should now have a database schema and an empty database file called dev.db. It's time to work on our codebase, which we shall do in the next section.

Creating our codebase

This section will be split into three parts. First, we will create some configuration files. Then, we will work on the backend API, and finally, we will complete our application by adding the code for our frontend architecture.

Setting up our configuration files

In this section, we have three files to work on. Our .env.local, layout.tsx and globals.css files.

We are starting with our .env.local file, which will hold the variables for our Arcject API key and our JWT_SECRET. The JWT_SECRET is a key that we will use to sign and verify the JSON Web Tokens (JWT) in our application. This is an essential step for securing authentication because it ensures that tokens can't be manipulated by people who don't have access.

In our example application, we will use the same JWT_SECRET for all users, as this is just a test application. It is not built for production.

We can generate a secret key using the built-in Node.js crypto module by running this code in the command line:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
로그인 후 복사

Now copy your Arcjet key, which you will find in the SDK CONFIGURATION section on your account, and the JWT_SECRET into the .env.local file. Take a look at the example below:

ARCJET_KEY=your_arcjet_key
JWT_SECRET=your_jwt_secret
로그인 후 복사

Ok, good, let's work on our layout.tsx file now. All you have to do is replace all of the code inside the existing file with this code here. We just added the Kulim_Park font for our application:

import type { Metadata } from 'next';
import { Kulim_Park } from 'next/font/google';
import './globals.css';

const kulim = Kulim_Park({
  subsets: ['latin', 'latin-ext'],
  weight: ['400', '600'],
});

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={kulim.className}>{children}</body>
    </html>
  );
}
로그인 후 복사

Lastly, go to the globals.css file and replace all of the code inside with this new one. We did some code cleanup and made the default font size 18px:

@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  font-size: 18px;
}
로그인 후 복사

That takes care of our configuration files. In the next section, we can start working on the backend rest API.

Building our backend

In this section, we have seven files to work on, so let's start.

Six of the seven files will be POST routes, and the other one will be a GET route. First, let's add the code for our route.ts file inside of apt/auth/[...nextauth]:

import arcjet, { tokenBucket } from '@arcjet/next';
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';

const prisma = new PrismaClient();

// Initialize Arcjet for the auth route
const aj = arcjet({
  key: process.env.ARCJET_KEY!,
  rules: [
    tokenBucket({
      mode: 'LIVE',
      refillRate: 5, // refill 5 tokens per interval
      interval: 30, // refill every 30 seconds
      capacity: 10, // bucket maximum capacity of 10 tokens
    }),
  ],
});

export async function POST(req: Request) {
  // Apply Arcjet protection to the login route
  const decision = await aj.protect(req, {
    requested: 5,
  });

  if (decision.isDenied()) {
    return NextResponse.json(
      { error: 'Too Many Requests', reason: decision.reason },
      { status: 429 }
    );
  }

  // Proceed with login logic
  const { email, password } = await req.json();

  try {
    const user = await prisma.user.findUnique({
      where: { email },
    });

    if (user && bcrypt.compareSync(password, user.password)) {
      const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, {
        expiresIn: '1h',
      });

      return NextResponse.json({ token });
    } else {
      return NextResponse.json(
        { error: 'Invalid credentials' },
        { status: 401 }
      );
    }
  } catch (error) {
    return NextResponse.json({ error: 'Login failed' }, { status: 500 });
  }
}
로그인 후 복사

This file holds the Arcjet security protection for our login route. Rate limiting is also in place for users who try to sign in too many times in quick succession.

Next, let's add the code for generating JWT tokens in the route inside of api/generatejwt/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET!;

export async function POST(request: NextRequest) {
  try {
    const { userId } = await request.json();

    // Validate that userId is a number
    if (typeof userId !== 'number') {
      return NextResponse.json({ error: 'Invalid userId' }, { status: 400 });
    }

    // Generate a JWT token
    const token = jwt.sign({ userId }, JWT_SECRET, { expiresIn: '1h' }); // Token expires in 1 hour

    return NextResponse.json({ token }, { status: 200 });
  } catch (error) {
    return NextResponse.json({ error: 'An error occurred' }, { status: 500 });
  }
}
로그인 후 복사

This route generates JWT tokens for different users and is dependent on their user ID. It is done automatically when we create a user and sign in. Its purpose in this file is to use Curl commands to add money to a user's bank account.

Right lets now add the code for our sign in route at api/signin/route.ts:

import { NextResponse, NextRequest } from 'next/server';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import arcjet, { detectBot } from '@arcjet/next';

const prisma = new PrismaClient();

// Initialize Arcjet with bot detection rule
const aj = arcjet({
  key: process.env.ARCJET_KEY!,
  rules: [
    detectBot({
      mode: 'LIVE', // Block automated clients
      block: ['AUTOMATED'], // Specifically block requests identified as automated
    }),
  ],
});

export async function POST(req: NextRequest) {
  const decision = await aj.protect(req);
  console.log('Arcjet decision: ', decision);

  if (decision.isDenied()) {
    // If the request is blocked, return a 403 Forbidden response
    return NextResponse.json(
      { error: 'Forbidden: Automated client detected', ip: decision.ip },
      { status: 403 }
    );
  }

  // Proceed with the usual login logic if not blocked
  const { email, password } = await req.json();

  const user = await prisma.user.findUnique({
    where: { email },
  });

  if (!user || !(await bcrypt.compare(password, user.password))) {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, {
    expiresIn: '1h',
  });

  return NextResponse.json({ token });
}
로그인 후 복사

The logic in this route is used to sign users into their bank accounts. The route has some Arcjet security protection set up to block automated clients.

The next route to work on will be api/signup/route.ts so add this code to the file:

import arcjet, { protectSignup } from '@arcjet/next';
import { NextResponse, NextRequest } from 'next/server';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';

const prisma = new PrismaClient();

// Initialize Arcjet with the protectSignup rule
const aj = arcjet({
  key: process.env.ARCJET_KEY!,
  rules: [
    protectSignup({
      email: {
        mode: 'LIVE', // Actively block invalid, disposable, or no MX record emails
        block: ['DISPOSABLE', 'INVALID', 'NO_MX_RECORDS'],
      },
      bots: {
        mode: 'LIVE', // Block clients identified as automated
        block: ['AUTOMATED'],
      },
      rateLimit: {
        mode: 'LIVE', // Actively enforce rate limits
        interval: '2m', // Sliding window of 2 minutes
        max: 5, // Maximum 5 submissions in the interval
      },
    }),
  ],
});

export async function POST(req: NextRequest) {
  // Parse the request body
  const { name, email, password } = await req.json();

  // Apply Arcjet protection to the signup route
  const decision = await aj.protect(req, { email });

  if (decision.isDenied()) {
    if (decision.reason.isEmail()) {
      let message: string;

      // Specific email error handling
      if (decision.reason.emailTypes.includes('INVALID')) {
        message = 'Email address format is invalid. Is there a typo?';
      } else if (decision.reason.emailTypes.includes('DISPOSABLE')) {
        message = 'We do not allow disposable email addresses.';
      } else if (decision.reason.emailTypes.includes('NO_MX_RECORDS')) {
        message =
          'Your email domain does not have an MX record. Is there a typo?';
      } else {
        message = 'Invalid email address.';
      }

      return NextResponse.json(
        { message, reason: decision.reason },
        { status: 400 }
      );
    } else if (decision.reason.isRateLimit()) {
      const reset = decision.reason.resetTime;

      if (reset === undefined) {
        return NextResponse.json(
          {
            message: 'Too many requests. Please try again later.',
            reason: decision.reason,
          },
          { status: 429 }
        );
      }

      // Calculate time until the rate limit resets
      const seconds = Math.floor((reset.getTime() - Date.now()) / 1000);
      const minutes = Math.ceil(seconds / 60);

      if (minutes > 1) {
        return NextResponse.json(
          {
            message: `Too many requests. Please try again in ${minutes} minutes.`,
            reason: decision.reason,
          },
          { status: 429 }
        );
      } else {
        return NextResponse.json(
          {
            message: `Too many requests. Please try again in ${seconds} seconds.`,
            reason: decision.reason,
          },
          { status: 429 }
        );
      }
    } else {
      return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
    }
  }

  // Proceed with signup logic
  const hashedPassword = bcrypt.hashSync(password, 10);

  try {
    await prisma.user.create({
      data: {
        name,
        email,
        password: hashedPassword,
        accountNumber: `ACC${Math.floor(Math.random() * 1000000)}`,
      },
    });
    return NextResponse.json({ message: 'User created successfully!' });
  } catch (error) {
    return NextResponse.json({ error: 'User already exists' }, { status: 400 });
  }
}
로그인 후 복사

This route has the logic for letting users sign up for an account on the platform. It also has Arcjet protection for blocking bots and invalid emails. Like before, there is rate limiting, too, as well as various error handling to cater to different use cases.

We are going to add the code for our transaction route now, which you can find in api/transaction/route.ts so add this code to the file:

import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';

const prisma = new PrismaClient();

function getUserIdFromToken(token: string): number | null {
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
      userId: number;
    };
    return decoded.userId;
  } catch (error) {
    return null;
  }
}

export async function POST(req: Request) {
  const authHeader = req.headers.get('Authorization');
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const token = authHeader.split(' ')[1];
  const userId = getUserIdFromToken(token);
  if (!userId) {
    return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
  }

  const { recipientAccount, amount } = await req.json();

  const sender = await prisma.user.findUnique({
    where: { id: userId },
  });
  const recipient = await prisma.user.findUnique({
    where: { accountNumber: recipientAccount },
  });

  if (!sender || !recipient) {
    return NextResponse.json({ error: 'Account not found' }, { status: 404 });
  }

  if (sender.balance < amount) {
    return NextResponse.json(
      { error: 'Insufficient balance' },
      { status: 400 }
    );
  }

  await prisma.user.update({
    where: { id: sender.id },
    data: { balance: { decrement: amount } },
  });

  await prisma.user.update({
    where: { id: recipient.id },
    data: { balance: { increment: amount } },
  });

  return NextResponse.json({ message: 'Transaction successful' });
}
로그인 후 복사

This is another one of the POST request routes used for sending money from one user account to another. It can check to see whether an account exists and is valid and tell if a user has enough balance in their account to send. Error handling is set up to check for different scenarios.

Now, we have our user route, which is the only GET route we have for our API. This code will go inside of api/user/route.ts:

import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';

const prisma = new PrismaClient();

function getUserIdFromToken(token: string): number | null {
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
      userId: number;
    };
    return decoded.userId;
  } catch (error) {
    return null;
  }
}

export async function GET(req: Request) {
  const authHeader = req.headers.get('Authorization');
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const token = authHeader.split(' ')[1];
  const userId = getUserIdFromToken(token);
  if (!userId) {
    return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
  }

  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { name: true, email: true, accountNumber: true, balance: true },
  });

  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 404 });
  }

  return NextResponse.json(user);
}
로그인 후 복사

The code in this file is used to find users inside our database. It also lets us know if a user is authorized and has a valid or invalid token for signing in.

Lastly, we need to add the code for our middleware.ts file which is inside our src/app folder:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import arcjet, { shield } from '@arcjet/next';

// Initialize Arcjet with shield protection
const aj = arcjet({
  key: process.env.ARCJET_KEY!,
  rules: [
    shield({
      mode: 'LIVE', // Enforce live protection
    }),
  ],
});

export async function middleware(req: NextRequest) {
  // Apply Arcjet protection
  const decision = await aj.protect(req);

  if (decision.isDenied()) {
    return NextResponse.json(
      { error: 'Forbidden: Suspicious activity detected' },
      { status: 403 }
    );
  }

  // Existing middleware logic for handling sign-in redirection
  const token = req.cookies.get('token');
  const url = req.nextUrl.clone();

  if (token && url.pathname === '/signin') {
    url.pathname = '/transaction';
    return NextResponse.redirect(url);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/signin', '/transaction/:path*'], // Apply middleware to these routes
};
로그인 후 복사

The middleware file applies more Arcjet protection to our routes and enforces live protection for the Arcjet shield. We even have sign-in redirection logic for routes which ensures that you can only access pages that you have authentication for.

Our backend is now complete. The only thing left is the front end, and then we can test our application.

Building our frontend

The front end is all that remains, and we have five files to add code to. Let's begin.

Our first file will be the Header.tsx file inside of components/Header.tsx and this is the code needed:

import Link from 'next/link';

export default function Header() {
  return (
    <>
      <header className="m-4">
        <nav className="flex flex-wrap justify-around">
          <Link href={'/'} className="font-bold">
            Home
          </Link>
          <Link href={'/signin'} className="font-bold">
            Sign in
          </Link>
          <Link
            href={'/signup'}
            className="bg-rose-400 pt-2 pr-4 pb-2 pl-4 rounded-full font-bold"
          >
            Register
          </Link>
        </nav>
      </header>
    </>
  );
}
로그인 후 복사

This file creates our main navigation component, which will be on every page. It also has the links for our home, sign-in, and register pages.

Now we will add the code for our sign-in page inside of signin/page.tsx:

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Header from '../components/Header';

export default function SignInPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      const response = await fetch('/api/auth/signin', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });

      if (response.ok) {
        const data = await response.json();
        localStorage.setItem('token', data.token);
        router.push('/transaction');
      } else {
        const errorData = await response.json();
        setError(errorData.error || 'Sign in failed');
      }
    } catch (error) {
      console.error('Sign in error:', error);
      setError('Unable to connect to the server. Please try again later.');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      <Header />
      <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
        <div className="max-w-md w-full space-y-8">
          <div>
            <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
              Sign in to your account
            </h2>
          </div>
          <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
            <input type="hidden" name="remember" value="true" />
            <div className="rounded-md shadow-sm -space-y-px">
              <div>
                <label htmlFor="email-address" className="sr-only">
                  Email address
                </label>
                <input
                  id="email-address"
                  name="email"
                  type="email"
                  autoComplete="email"
                  required
                  className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-rose-500 focus:border-rose-500 focus:z-10 sm:text-sm"
                  placeholder="Email address"
                  value={email}
                  onChange={(e) => setEmail(e.target.value)}
                />
              </div>
              <div>
                <label htmlFor="password" className="sr-only">
                  Password
                </label>
                <input
                  id="password"
                  name="password"
                  type="password"
                  autoComplete="current-password"
                  required
                  className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-rose-500 focus:border-rose-500 focus:z-10 sm:text-sm"
                  placeholder="Password"
                  value={password}
                  onChange={(e) => setPassword(e.target.value)}
                />
              </div>
            </div>

            {error && <div className="text-red-500 text-sm mt-2">{error}</div>}

            <div>
              <button
                type="submit"
                disabled={isLoading}
                className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-rose-400 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500"
              >
                {isLoading ? 'Signing in...' : 'Sign in'}
              </button>
            </div>
          </form>
        </div>
      </div>
    </div>
  );
}
로그인 후 복사

This is our sign-in page, which is pretty straightforward. It has one form for signing users into their accounts after retrieving them from the database. The form also has an error-handling setup.

Ok, we have our sign-in form, so now we should do our sign-up form, which you can find on the signup/page.tsx and this is the code to add to it:

'use client';
import { useState } from 'react';
import axios from 'axios';
import { useRouter } from 'next/navigation';
import Header from '../components/Header';

export default function SignUp() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    password: '',
  });

  const [error, setError] = useState<string | null>(null); // State for storing error messages
  const router = useRouter();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setFormData({ ...formData, [e.target.name]: e.target.value });
    setError(null); // Clear error message when input changes
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null); // Clear any previous errors

    try {
      await axios.post('/api/signup', formData);
      router.push('/signin');
    } catch (err) {
      if (axios.isAxiosError(err) && err.response) {
        const { status, data } = err.response;

        // Handle specific error messages from the backend
        if (status === 400) {
          setError(data.message || 'Invalid email address.');
        } else if (status === 429) {
          setError('Too many requests. Please try again later.');
        } else {
          setError('An error occurred during sign up. Please try again.');
        }
      } else {
        setError('An unexpected error occurred. Please try again.');
      }
    }
  };

  return (
    <div>
      <Header />

      <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
        <div className="max-w-md w-full space-y-8">
          <div>
            <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
              Create an account
            </h2>
          </div>
          <form onSubmit={handleSubmit} className="mt-8">
            <div>
              <input
                type="text"
                name="name"
                placeholder="Name"
                value={formData.name}
                onChange={handleChange}
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-rose-500 focus:border-rose-500 focus:z-10 sm:text-sm"
              />
            </div>
            <div>
              <input
                type="email"
                name="email"
                placeholder="Email"
                value={formData.email}
                onChange={handleChange}
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900  focus:outline-none focus:ring-rose-500 focus:border-rose-500 focus:z-10 sm:text-sm"
              />
            </div>
            <div>
              <input
                type="password"
                name="password"
                placeholder="Password"
                value={formData.password}
                onChange={handleChange}
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-rose-500 focus:border-rose-500 focus:z-10 sm:text-sm"
              />
            </div>
            <div>
              <button
                type="submit"
                className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-rose-400 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500 mt-8 mb-8"
              >
                Sign Up
              </button>
            </div>
            {error && <p className="text-red-500 text-center">{error}</p>}{' '}
            {/* Display error message */}
          </form>
        </div>
      </div>
    </div>
  );
}
로그인 후 복사

Our signup form is very similar to our sign-in form. In this case, it creates user accounts, which are then saved in our database. Like our previous form, it also has error handling.

Just two more files left and our application is completed. The file we will work on now is for transactions, and you can find it inside of transaction/page.tsx. Make sure you add this code to the file:

'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Header from '../components/Header';
interface UserInfo {
  name: string;
  email: string;
  accountNumber: string;
  balance: number;
}

export default function TransactionPage() {
  const [recipientAccount, setRecipientAccount] = useState('');
  const [amount, setAmount] = useState(0);
  const [message, setMessage] = useState('');
  const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
  const router = useRouter();

  useEffect(() => {
    const token = localStorage.getItem('token');
    if (!token) {
      router.push('/signin');
    } else {
      fetchUserInfo(token);
    }
  }, []); // Empty dependency array to run only once on mount

  const fetchUserInfo = async (token: string) => {
    try {
      const res = await fetch('/api/user', {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });
      if (res.ok) {
        const data = await res.json();
        setUserInfo(data);
      } else {
        // Token might be invalid or expired
        localStorage.removeItem('token');
        router.push('/signin');
      }
    } catch (error) {
      console.error('Error fetching user info:', error);
      setMessage('Error fetching user information');
    }
  };

  const handleSignOut = () => {
    localStorage.removeItem('token');
    router.push('/signin');
  };

  const handleTransaction = async (e: React.FormEvent) => {
    e.preventDefault();
    const token = localStorage.getItem('token');
    if (!token) {
      router.push('/signin');
      return;
    }

    const res = await fetch('/api/transaction', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({ recipientAccount, amount }),
    });

    if (res.ok && amount > 0) {
      setMessage('Transaction successful!');
      fetchUserInfo(token); // Refresh user info after successful transaction
      setRecipientAccount('');
      setAmount(0);
    } else if (amount <= 0) {
      setMessage(`Error: The amount you send, needs to be higher than 0`);
    } else {
      const errorData = await res.json();
      setMessage(`Error: ${errorData.error}`);
    }
  };

  if (!userInfo) {
    return <p>Loading...</p>;
  }

  return (
    <div>
      <div>
        <Header />
      </div>
      <div className="container mx-auto p-4">
        <h1 className="text-2xl font-bold mb-4">Transaction Page</h1>
        <div className="mb-4">
          <h1 className="text-2xl">{userInfo.name}</h1>
          <p className="mt-4 mb-4">Email: {userInfo.email}</p>
          <p className="mt-4 mb-4">Account Number: {userInfo.accountNumber}</p>
          <p className="bg-slate-200 p-4">
            Balance: ${userInfo.balance.toFixed(2)}
          </p>
        </div>
        <button
          onClick={handleSignOut}
          className="bg-slate-800 text-white px-4 py-2 rounded mb-4"
        >
          Sign Out
        </button>
        <form onSubmit={handleTransaction} className="space-y-4">
          <input
            type="text"
            value={recipientAccount}
            onChange={(e) => setRecipientAccount(e.target.value)}
            placeholder="Recipient Account Number"
            className="w-full p-2 border rounded"
          />
          <input
            type="number"
            value={amount}
            onChange={(e) => setAmount(Number(e.target.value))}
            placeholder="Amount"
            className="w-full p-2 border rounded"
          />
          <button
            type="submit"
            className="bg-green-400 text-white px-4 py-2 rounded"
          >
            Send
          </button>
        </form>
        {message && <p className="mt-4 text-center font-bold">{message}</p>}
      </div>
    </div>
  );
}
로그인 후 복사

This is our transaction page which all users will use to view their account details and do transactions between accounts.

Lastly, let's complete our application by adding this code to our main page.tsx file inside of the src/app folder:

'use client';
import { useState } from 'react';
import Header from './components/Header';

export default function Home() {
  const [userId, setUserId] = useState<number | ''>('');
  const [token, setToken] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (typeof userId !== 'number') {
      setError('User ID must be a number');
      return;
    }

    try {
      const response = await fetch('/api/generatejwt', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ userId }),
      });

      const data = await response.json();

      if (response.ok && userId !== 0) {
        setToken(data.token);
        setError(null);
      } else if (userId === 0) {
        setError('Id needs to be higher than zero');
        setToken(null);
      } else {
        setError(data.error || 'Failed to generate token');
        setToken(null);
      }
    } catch (err) {
      setError('An unexpected error occurred');
      setToken(null);
    }
  };

  const curlCommand = `curl -v POST http://localhost:3000/api/transaction \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyLCJpYXQiOjE3MjQ1MDMwMTUsImV4cCI6MTcyNDUwNjYxNX0.h2i9_-PgEq2W76rAABRqTQesBIf1LSugK6_ILE9pKM8" \
-H "Content-Type: application/json" \
-d '{"recipientAccount": "ACC616021", "amount": 1000}'`;

  return (
    <div>
      <Header />
      <main className="flex flex-col flex-wrap justify-center">
        <article className="text-center m-4">
          <h1 className="text-6xl uppercase mb-4">
            A single account for all your money needs worldwide!
          </h1>
          <p className="pt-4 pr-4 pb-4 pl-4 text-2xl md:pl-4 md:pr-4 sm:pl-4 sm:pr-4">
            Sending money should not be difficult. Easily generate income
            worldwide with just a few clicks.
          </p>
        </article>

        <section className="bg-rose-400 p-6 text-white">
          <article className="text-left m-4">
            <h1 className="text-3xl mb-4">
              How to add money to the balance of a user account
            </h1>
            <p className="mt-4 mb-4">
              Sign-in and registration forms work; however, all users start with
              zero balance in their account.
            </p>
            <p>
              We can simulate a bank transfer by using an SQL query to add funds
              to a user's bank balance. We can also simulate bank transfers
              between users by generating a valid JWT Token for a user in the
              database. These methods will let us send money to a user's bank
              account as long as the user who is sending the money has the funds
              available. All users start with a balance of zero when they
              register for an account, and users will need money in their bank
              accounts so that we can test internal bank transactions between
              users.
            </p>
            <h2 className="text-2xl mt-4 mb-4">Add money using SQL</h2>
            <p className="mt-4 mb-4">
              First, register some users and create accounts for them if you
              still need to do so. Now connect to your SQLite database, find a
              user whose account you want to add funds to, and make a note of
              their ID. Now copy this SQL query; you can change the balance if
              you want to. The important thing is that you change the ID to the
              account that you want to credit. Run the SQL query and refresh or
              check your database. The user should now have funds in their
              account under balance.
            </p>
            <textarea className="text-black p-2 h-40 w-full">
              UPDATE User SET balance = 100000000 WHERE id = 10;
            </textarea>
            <h2 className="text-2xl mt-4 mb-4">
              Use a Curl command to add money into a user's account
            </h2>
            <p>
              Remember that users need funds inside their accounts before they
              can make bank transfers. So, you have to do this using an SQL
              query first; otherwise, the user will have insufficient funds to
              send.
            </p>
            <p className="mt-4 mb-4">
              Locate the user's ID inside the SQLite database and then enter the
              ID number into the form input. If you are using VSCode you can use
              the SQLite Viewer extension to view the data inside of the SQLite
              database located inside <code>prisma/dev.db</code>.
            </p>
            <p className="mt-4 mb-4">
              Generate a token and then go to the next step where we create the
              Curl command.
            </p>
          </article>

          <section className="flex justify-center text-center">
            <form
              onSubmit={handleSubmit}
              className="flex flex-col justify-center bg-slate-800 p-4 rounded"
            >
              <div className="flex flex-col">
                <label htmlFor="userId">User Id:</label>
                <input
                  type="number"
                  id="userId"
                  value={userId}
                  onChange={(e) => setUserId(Number(e.target.value))}
                  required
                  className="text-black p-1"
                />
              </div>
              <div className="mt-4">
                <button type="submit" className="bg-slate-600 p-2 rounded">
                  Generate Token
                </button>
              </div>
            </form>
          </section>
          <section className="text-center mt-10">
            {token && (
              <div>
                <p className="font-bold">Generated Token:</p>
                <textarea
                  value={token}
                  className="text-black w-full h-20 mt-4 p-2"
                ></textarea>
              </div>
            )}
            {error && (
              <div>
                <h2>Error:</h2>
                <p>{error}</p>
              </div>
            )}
          </section>
          <section>
            <p className="mt-4 mb-4">
              Now copy the curl command below into your code editor so that you
              can edit it.
            </p>
            <textarea
              value={curlCommand}
              className="text-black p-2 h-40 w-full"
            ></textarea>
            <p className="mt-4 mb-4">
              1. It's crucial to replace the example JWT token with your
              generated token. Next, go to your SQLite database and find the
              account number of a user to whom you want to send money. Make sure
              that you choose a different user from the one you generated a
              token for because obviously a user is not going to be sending
              money to themselves in this case. Its only between different
              users. The user for whom you generated a token should obviously
              have a balance in their account so that they have funds to send.
              Now replace the value for the <code>recipientAccount</code> number
              with the other user you chose.
            </p>
            <p className="mt-4 mb-4">
              2. Next, change the value of the amount to whatever you want. Just
              remember that it can't be higher than the balance the user has
              available in their account; otherwise, you will get the
              insufficient balance error when you run the curl command.
            </p>
            <p className="mt-4 mb-4">
              3. Finally, run the curl command in your command line. This action
              will successfully simulate a bank transfer between users' bank
              accounts, marking a successful completion of the process.
            </p>
          </section>
        </section>
      </main>
    </div>
  );
}
로그인 후 복사

This is our main homepage, and it is essential because it also has instructions for adding money to users' accounts. All users start with a balance of zero, so we have to simulate a bank transfer and add money to their accounts before we can test out the different transactions.

We can do this by using an SQL query like in this example:

UPDATE User SET balance = 100000000 WHERE id = 10;
로그인 후 복사

Essentially, we find a user inside our database and set a number of our choice for their bank account balance. This balance will go up and down when a user sends and receives money from other users, just like it would in a real bank account. In this example, it's just numbers, not real money, because there is no payment gateway in our application.

Using our personal finance application

We should now have a working Minimum Viable Product (MVP). To start the application, run the usual Next.js run command in your terminal:

npm run dev
로그인 후 복사

Let's learn how to use the application. First, you need to create some new users, which can be done by going to the register page. After you have created a user, you should be able to log in with the email and password that you chose.

You should now see the transaction page. You can sign out of your account or send money to another user. Obviously, you will need to create multiple users so that you have accounts to send to. All users start with a balance of zero, so follow the instructions on the home page to add money to a user's account.

If you want to view the data inside your SQLite database, you can use a VS Code extension like SQLite Viewer, although you won't be able to run any SQL queries. Alternatively, you can use the command line by referring to the SQLite Documentation or a database management tool like TablePlus.

Testing Arcjet Protection in our app

Okay, our application is up and running, so now we can test the Arcjet protection we have implemented and see it working in real-time. Our app should have:

  • Signup form protection
  • Bot protection
  • Rate limiting

If you sign in to your Arcjet account and go to the dashboard, you should be able to see the requests and analytics for your Arcjet protection layer, as seen in this example:

Building a Personal Finance App with Arcjet

Signup form protection

To see the signup form protection in action, go to the Register page and then try these emails to see what happens:

  • invalid.@arcjet – is an invalid email address.
  • test@0zc7eznv3rsiswlohu.tk – is from a disposable email provider.
  • nonexistent@arcjet.ai – is a valid email address & domain, but has no MX records.

The form should show different errors depending on the type of email address that you use and even if you forget to enter an email address altogether as shown in the examples below:

Invalid email and address and no email address error

Building a Personal Finance App with Arcjet

Disposable email address error

Building a Personal Finance App with Arcjet

No MX record error

Building a Personal Finance App with Arcjet

Bot Protection

Send a post request to the sign-in route using the command line with this code here:

curl -v POST http://localhost:3000/api/signin -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"password"}'
로그인 후 복사

You should get an error, like this which confirms that the Arcjet bot protection is working:

* Could not resolve host: POST
* Closing connection
curl: (6) Could not resolve host: POST
* Host localhost:3000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:3000...
* Connected to localhost (::1) port 3000
> POST /api/signin HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 50
>
* upload completely sent off: 50 bytes
< HTTP/1.1 403 Forbidden
< vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch
< content-type: application/json
< Date: Wed, 28 Aug 2024 12:08:22 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< Transfer-Encoding: chunked
<
* Connection #1 to host localhost left intact
{"error":"Forbidden: Automated client detected","ip":{}}%
로그인 후 복사

Rate limiting

One example of rate limiting can be seen on the Sign-in page. If you try to sign into user accounts more than twice in quick succession, then you will get a Too Many Requests error as shown here:

Building a Personal Finance App with Arcjet

This is because users can only sign in two times quickly, and then they have to wait 30 seconds before they can try again.

This number can be adjusted inside of the src/api/auth/[...nextauth] route inside of this code block:

const aj = arcjet({
  key: process.env.ARCJET_KEY!,
  rules: [
    tokenBucket({
      mode: 'LIVE',
      refillRate: 5, // refill 5 tokens per interval
      interval: 30, // refill every 30 seconds
      capacity: 10, // bucket maximum capacity of 10 tokens

      // Users can only sign in two times in quick succession and then they have to wait 30 seconds before they can try again each time
    }),
  ],
});
로그인 후 복사

Conclusion

Well, we are done, our app is complete! You have created a simple personal finance app. In this tutorial, we went through how to set up a simple and user-friendly application that can perform transactions between accounts. In order for your app to not only run smoothly but also maintain some sense of security and reliability, we incorporated Arcjet's powerful tools, such as Arcjet Shield, Rate Limiting, Bot Protection, Email Validation, and Signup Form Protection.

Now that you have a good understanding of these technologies and how they work together, you can leverage their essential construction elements to add more features to your app or scale it up with additional security features. This newfound knowledge gives you the power to take your app to the next level.

If you want more information on this topic, take a look at Arcjet's official documentation.

위 내용은 Arcjet으로 개인 금융 앱 구축의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

원천:dev.to
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿