feat: add login

This commit is contained in:
WH64 2025-03-18 16:20:49 +09:00
parent 5413244e60
commit 295f3ecd14
15 changed files with 559 additions and 13 deletions

45
app.go
View file

@ -1,8 +1,10 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"syscall"
"git.wh64.net/devproje/kuma-archive/config" "git.wh64.net/devproje/kuma-archive/config"
"git.wh64.net/devproje/kuma-archive/internal/routes" "git.wh64.net/devproje/kuma-archive/internal/routes"
@ -11,6 +13,7 @@ import (
"github.com/devproje/commando/option" "github.com/devproje/commando/option"
"github.com/devproje/commando/types" "github.com/devproje/commando/types"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/term"
) )
var ( var (
@ -40,6 +43,9 @@ func main() {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
// init auth module
service.NewAuthService()
gin := gin.Default() gin := gin.Default()
routes.New(gin, ver, apiOnly) routes.New(gin, ver, apiOnly)
@ -65,6 +71,45 @@ func main() {
return nil return nil
}) })
command.ComplexRoot("account", "file server account manager", []commando.Node{
command.Then("create", "create account", func(n *commando.Node) error {
var username, password string
fmt.Print("new username: ")
if _, err := fmt.Scanln(&username); err != nil {
return fmt.Errorf("failed to read username: %v", err)
}
fmt.Print("new password: ")
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read password: %v", err)
}
password = string(bytePassword)
fmt.Println()
fmt.Print("type new password one more time: ")
checkByte, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read password: %v", err)
}
check := string(checkByte)
fmt.Println()
if password != check {
return errors.New("password check is not correct")
}
auth := service.NewAuthService()
if err := auth.Create(&service.Account{Username: username, Password: password}); err != nil {
return err
}
fmt.Printf("Account for %s created successfully\n", username)
return nil
}),
})
if err := command.Execute(); err != nil { if err := command.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err) fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1) os.Exit(1)

1
go.mod
View file

@ -33,6 +33,7 @@ require (
golang.org/x/crypto v0.36.0 // indirect golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.37.0 // indirect golang.org/x/net v0.37.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.30.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

2
go.sum
View file

@ -85,6 +85,8 @@ golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=

View file

@ -106,6 +106,38 @@ func New(app *gin.Engine, version *service.Version, apiOnly bool) {
ctx.FileAttachment(data.Path, data.Name) ctx.FileAttachment(data.Path, data.Name)
}) })
auth := api.Group("/auth")
{
auth.POST("/login", func(ctx *gin.Context) {
auth := service.NewAuthService()
username := ctx.PostForm("username")
password := ctx.PostForm("password")
acc, err := auth.Read(username)
if err != nil {
ctx.JSON(401, gin.H{
"ok": 0,
"errno": "username or password not invalid",
})
return
}
ok, err := auth.Verify(username, password)
if err != nil || !ok {
ctx.JSON(401, gin.H{
"ok": 0,
"errno": "username or password not invalid",
})
return
}
ctx.JSON(200, gin.H{
"ok": 1,
"token": auth.Token(acc.Username, acc.Password),
})
})
}
api.GET("/version", func(ctx *gin.Context) { api.GET("/version", func(ctx *gin.Context) {
ctx.String(200, "%s", version.String()) ctx.String(200, "%s", version.String())
}) })

168
internal/service/auth.go Normal file
View file

@ -0,0 +1,168 @@
package service
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"strings"
)
type AuthService struct{}
type Account struct {
Username string
Password string
Salt string
}
func NewAuthService() *AuthService {
return &AuthService{}
}
func init() {
db, err := Open()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
return
}
defer db.Close()
stmt, err := db.Prepare(strings.TrimSpace(`
create table Account(
username varchar(25),
password varchar(255),
salt varchar(50),
primary key (username)
);
`))
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
return
}
defer stmt.Close()
if _, err = stmt.Exec(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
return
}
}
func (s *AuthService) Create(data *Account) error {
db, err := Open()
if err != nil {
return err
}
defer db.Close()
stmt, err := db.Prepare("insert into Account values(?, ?, ?);")
if err != nil {
return err
}
defer stmt.Close()
salt := genSalt()
if _, err = stmt.Exec(data.Username, encrypt(data.Password, salt), salt); err != nil {
return err
}
return nil
}
func (s *AuthService) Read(username string) (*Account, error) {
db, err := Open()
if err != nil {
return nil, err
}
defer db.Close()
stmt, err := db.Prepare("select * from Account where username = ?;")
if err != nil {
return nil, err
}
defer stmt.Close()
var account Account
if err := stmt.QueryRow(username).Scan(&account.Username, &account.Password, &account.Salt); err != nil {
return nil, err
}
return &account, nil
}
func (s *AuthService) Update(username, password string) error {
db, err := Open()
if err != nil {
return err
}
defer db.Close()
stmt, err := db.Prepare("update Account set password = ?, salt = ? where username = ?;")
if err != nil {
return err
}
defer stmt.Close()
salt := genSalt()
if _, err = stmt.Exec(encrypt(password, salt), salt, username); err != nil {
return err
}
return nil
}
func (s *AuthService) Delete(username string) error {
db, err := Open()
if err != nil {
return err
}
defer db.Close()
stmt, err := db.Prepare("delete from Account where username = ?;")
if err != nil {
return err
}
defer stmt.Close()
if _, err = stmt.Exec(username); err != nil {
return err
}
return nil
}
func (s *AuthService) Verify(username, password string) (bool, error) {
account, err := s.Read(username)
if err != nil {
return false, err
}
if encrypt(password, account.Salt) == account.Password {
return true, nil
}
return false, nil
}
func (s *AuthService) Token(username, password string) string {
raw := fmt.Sprintf("%s:%s", username, password)
return base64.StdEncoding.EncodeToString([]byte(raw))
}
func encrypt(password, salt string) string {
hash := sha256.New()
hash.Write([]byte(password + salt))
return hex.EncodeToString(hash.Sum(nil))
}
func genSalt() string {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
return ""
}
return hex.EncodeToString(b)
}

View file

@ -2,11 +2,12 @@ package service
import ( import (
"database/sql" "database/sql"
"path/filepath"
"git.wh64.net/devproje/kuma-archive/config" "git.wh64.net/devproje/kuma-archive/config"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
func Open() (*sql.DB, error) { func Open() (*sql.DB, error) {
return sql.Open("sqlite3", (config.ROOT_DIRECTORY)) return sql.Open("sqlite3", filepath.Join(config.ROOT_DIRECTORY, "data.db"))
} }

View file

@ -31,9 +31,36 @@
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
.link { .link {
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.login-info {
display: flex;
align-items: center;
justify-content: center;
span {
margin-left: 10px;
}
}
.login-btn {
display: flex;
margin-left: 0.5rem;
align-items: center;
border-radius: 25px;
padding: 0.45rem 1rem;
justify-content: center;
transition-duration: 300ms;
background-color: var(--nav-color);
&:hover {
transition-duration: 300ms;
background-color: var(--nav-hover);
}
}
} }
} }

View file

@ -9,18 +9,33 @@ import { BrowserRouter, Route, Routes, useLocation } from "react-router";
import "./App.scss"; import "./App.scss";
import kuma from "./assets/kuma.png"; import kuma from "./assets/kuma.png";
import NotFound from "./components/notfound"; import NotFound from "./components/notfound";
import Login from "./components/login";
import { useAuthStore } from "./store/auth";
import Logout from "./components/logout";
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path={"*"} element={<Dashboard />} /> <Route path="/login" element={<Dashboard children={<Login />} />} />
<Route path="/logout" element={<Logout />} />
<Route path={"*"} element={<Dashboard children={<View />} />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
); );
} }
function Dashboard() { function Dashboard({ children }: { children: React.ReactNode }) {
return (
<main className="container-md ka-view">
<Header />
{children}
<Footer />
</main>
);
}
function View() {
const path = usePath(); const path = usePath();
const location = useLocation(); const location = useLocation();
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
@ -39,16 +54,20 @@ function Dashboard() {
return <></>; return <></>;
} }
return ( if (typeof path.data === "undefined") {
<main className="container-md ka-view"> return <NotFound />;
<Header /> }
{typeof path.data !== "undefined" ? path.data.is_dir ? <Directory /> : <FileView /> : <NotFound />}
<Footer /> if (path.data.is_dir) {
</main> return <Directory />;
); }
return <FileView />;
} }
function Header() { function Header() {
const auth = useAuthStore();
return ( return (
<nav className="ka-nav"> <nav className="ka-nav">
<a className="title" href="/"> <a className="title" href="/">
@ -63,6 +82,20 @@ function Header() {
<a className="link" href="https://projecttl.net"> <a className="link" href="https://projecttl.net">
<DynamicIcon name="globe" size={15} /> <DynamicIcon name="globe" size={15} />
</a> </a>
{!auth.token ? (
<a className="login-btn" href="/login">
Login
</a>
) : (
<div className="login-info">
<span>Logged in as Admin</span>
<a className="login-btn" href="/logout">
Logout
</a>
</div>
)}
</div> </div>
</nav> </nav>
); );

View file

@ -27,7 +27,7 @@
#download { #download {
margin: 1rem 0; margin: 1rem 0;
a { .download-btn {
margin: 0 5px; margin: 0 5px;
span { span {
margin-left: 5px; margin-left: 5px;

View file

@ -107,7 +107,7 @@ function FileView() {
{convert(path.data.total)} {convert(path.data.total)}
<div id="download"> <div id="download">
<button className="primary" id="download" onClick={ev => { <button className="download-btn secondary" onClick={ev => {
ev.preventDefault(); ev.preventDefault();
const link = document.createElement("a"); const link = document.createElement("a");
link.href = `/api/download${path.data?.path}`; link.href = `/api/download${path.data?.path}`;

View file

@ -0,0 +1,100 @@
import { useRef, useState } from "react";
import "./login.scss";
import project from "../../assets/kuma.png";
import { DynamicIcon } from "lucide-react/dynamic";
import { AuthData, useAuthStore } from "../../store/auth";
function Login() {
const [show, setShow] = useState(false);
const auth = useAuthStore();
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const errnoRef = useRef<HTMLInputElement>(null);
if (auth.token !== null)
document.location.href = "/";
return (
<div className="ka-login">
<form className="login-form">
<div className="logo">
<img src={project} />
<h1>Kuma Archive Login</h1>
</div>
<div className="input-area">
<div className="input">
<div className="input-icon">
<DynamicIcon name="user" size={20} />
</div>
<input ref={usernameRef} placeholder="Username" type="text" required />
<div className="dummy"></div>
</div>
<div className="input">
<div className="input-icon">
<DynamicIcon name="key-round" size={20} />
</div>
<input ref={passwordRef} placeholder="Password" type={show ? "text" : "password"} required />
<a onClick={ev => {
ev.preventDefault();
setShow(!show);
}}>
<DynamicIcon name={show ? "eye-off" : "eye"} size={20} />
</a>
</div>
</div>
<div className="submit-area">
<span className="errno" ref={errnoRef}></span>
<button type="submit" className="login-btn success" onClick={ev => {
ev.preventDefault();
const username = usernameRef.current?.value;
const password = passwordRef.current?.value;
if (!errnoRef)
return;
if (!username || !password) {
alert("username or password is empty!");
return;
}
if (username === "" || password === "") {
alert("username or password is empty!");
return;
}
const form = new URLSearchParams();
form.append("username", username);
form.append("password", password);
errnoRef.current!.innerText = "";
fetch("/api/auth/login", {
method: "POST",
body: form
}).then((res) => {
if (res.status !== 200) {
errnoRef.current!.innerText = "username or password is not invalid";
return;
}
res.json().then((data: AuthData) => {
auth.setToken(data.token);
window.location.href = "/";
});
});
}}>
<DynamicIcon name="log-in" size={20} />
<span>Login</span>
</button>
</div>
</form>
</div>
);
}
export default Login;

View file

@ -0,0 +1,98 @@
.ka-login {
width: 100%;
display: flex;
margin: 2rem 0;
align-items: center;
flex-direction: column;
justify-content: center;
.login-form {
width: 400px;
height: 500px;
display: flex;
padding: 4rem 2rem;
border-radius: 15px;
align-items: center;
flex-direction: column;
justify-content: space-between;
background-color: var(--nav-color);
.logo {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
img {
width: 120px;
height: 120px;
}
h1 {
margin-top: 10px;
}
}
.input-area {
width: 100%;
height: fit-content;
input {
border-radius: 0 25px 25px 0;
width: 100%;
}
.input-icon {
padding: 0 15px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 25px 0 0 25px;
background-color: var(--background);
}
.input {
display: flex;
margin-bottom: 5px;
input {
padding: 0.25rem 0;
border-radius: 0;
}
a, .dummy {
display: flex;
min-width: 50px;
min-height: 40px;
align-items: center;
justify-content: center;
border-radius: 0 25px 25px 0;
background-color: var(--background);
}
}
}
.submit-area {
width: 100%;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
.errno {
margin-bottom: 2px;
}
.login-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
span {
margin-left: 5px;
}
}
}
}
}

View file

@ -0,0 +1,11 @@
import { useAuthStore } from "../../store/auth";
function Logout() {
const auth = useAuthStore();
auth.clearToken();
document.location.href = "/";
return <></>;
}
export default Logout;

View file

@ -7,7 +7,7 @@ function NotFound() {
<DynamicIcon className="icon" name="file-question" size={120} /> <DynamicIcon className="icon" name="file-question" size={120} />
<h1>404 Not Found</h1> <h1>404 Not Found</h1>
<button className="primary" onClick={ev => { <button className="secondary" onClick={ev => {
ev.preventDefault(); ev.preventDefault();
document.location.href = "/"; document.location.href = "/";
}}>Back to home</button> }}>Back to home</button>

28
src/store/auth.ts Normal file
View file

@ -0,0 +1,28 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface AuthData {
ok: number;
token: string;
}
interface AuthState {
token: string | null;
setToken: (token: string) => void;
clearToken: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
setToken: (token) => set({ token }),
clearToken: () => set({ token: null }),
}),
{
name: "auth-storage"
}
)
);