feat(be/fe): enable captcha (#69)

* feat(ui): enable captcha

* feat(server): enable captcha

* fix(ui): fix login pane layout

* fix(config): remove unused config and files

* fix(be/fe): clean up code

* chore(fe/be): clean up code
This commit is contained in:
Hexxa 2021-08-06 22:27:24 -05:00 committed by GitHub
parent 021e5090be
commit 1fcb2223a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 262 additions and 82 deletions

View file

@ -48,7 +48,7 @@ export interface ListUploadingsResp {
}
export interface IUsersClient {
login: (user: string, pwd: string) => Promise<Response>;
login: (user: string, pwd: string, captchaId: string, captchaInput:string) => Promise<Response>;
logout: () => Promise<Response>;
isAuthed: () => Promise<Response>;
self: () => Promise<Response>;
@ -60,6 +60,7 @@ export interface IUsersClient {
addRole: (role: string) => Promise<Response>;
delRole: (role: string) => Promise<Response>;
listRoles: () => Promise<Response>;
getCaptchaID: () => Promise<Response>;
}
export interface IFilesClient {

View file

@ -5,13 +5,15 @@ export class UsersClient extends BaseClient {
super(url);
}
login = (user: string, pwd: string): Promise<Response> => {
login = (user: string, pwd: string, captchaId: string, captchaInput:string): Promise<Response> => {
return this.do({
method: "post",
url: `${this.url}/v1/users/login`,
data: {
user,
pwd,
captchaId,
captchaInput,
},
});
};
@ -114,4 +116,12 @@ export class UsersClient extends BaseClient {
params: {},
});
};
getCaptchaID = (): Promise<Response> => {
return this.do({
method: "get",
url: `${this.url}/v1/captchas/`,
params: {},
});
};
}

View file

@ -15,6 +15,7 @@ export class MockUsersClient {
private delRoleMockResp: Promise<Response>;
private listRolesMockResp: Promise<Response>;
private selfMockResp: Promise<Response>;
private getCaptchaIDMockResp: Promise<Response>;
constructor(url: string) {
this.url = url;
@ -22,86 +23,93 @@ export class MockUsersClient {
loginMock = (resp: Promise<Response>) => {
this.loginMockResp = resp;
}
};
logoutMock = (resp: Promise<Response>) => {
this.logoutMockResp = resp;
}
};
isAuthedMock = (resp: Promise<Response>) => {
this.isAuthedMockResp = resp;
}
};
setPwdMock = (resp: Promise<Response>) => {
this.setPwdMockResp = resp;
}
};
forceSetPwdMock = (resp: Promise<Response>) => {
this.forceSetPwdMockResp = resp;
}
};
addUserMock = (resp: Promise<Response>) => {
this.addUserMockResp = resp;
}
};
delUserMock = (resp: Promise<Response>) => {
this.delUserMockResp = resp;
}
};
listUsersMock = (resp: Promise<Response>) => {
this.listUsersMockResp = resp;
}
};
addRoleMock = (resp: Promise<Response>) => {
this.addRoleMockResp = resp;
}
};
delRoleMock = (resp: Promise<Response>) => {
this.delRoleMockResp = resp;
}
};
listRolesMock = (resp: Promise<Response>) => {
this.listRolesMockResp = resp;
}
slefMock = (resp: Promise<Response>) => {
};
selfMock = (resp: Promise<Response>) => {
this.selfMockResp = resp;
}
};
getCaptchaIDMock = (resp: Promise<Response>) => {
this.getCaptchaIDMockResp = resp;
};
login = (user: string, pwd: string): Promise<Response> => {
return this.loginMockResp;
}
};
logout = (): Promise<Response> => {
return this.logoutMockResp;
}
};
isAuthed = (): Promise<Response> => {
return this.isAuthedMockResp;
}
};
setPwd = (oldPwd: string, newPwd: string): Promise<Response> => {
return this.setPwdMockResp;
}
};
forceSetPwd = (userID: string, newPwd: string): Promise<Response> => {
return this.forceSetPwdMockResp;
}
};
addUser = (name: string, pwd: string, role: string): Promise<Response> => {
return this.addUserMockResp;
}
};
delUser = (userID: string): Promise<Response> => {
return this.delUserMockResp;
}
};
listUsers = (): Promise<Response> => {
return this.listUsersMockResp;
}
};
addRole = (role: string): Promise<Response> => {
return this.addRoleMockResp;
}
};
delRole = (role: string): Promise<Response> => {
return this.delRoleMockResp;
}
};
listRoles = (): Promise<Response> => {
return this.listRolesMockResp;
}
};
self = (): Promise<Response> => {
return this.selfMockResp;
}
};
getCaptchaID = (): Promise<Response> => {
return this.getCaptchaIDMockResp;
};
}

View file

@ -50,6 +50,7 @@ export function initState(): ICoreState {
displaying: "browser",
authPane: {
authed: false,
captchaID: "",
},
browser: {
isVertical: isVertical(),
@ -65,6 +66,7 @@ export function initState(): ICoreState {
paneNames: Set<string>(["settings", "login", "admin"]),
login: {
authed: false,
captchaID: "",
},
admin: {
users: Map<string, User>(),
@ -83,6 +85,7 @@ export function mockState(): ICoreState {
displaying: "browser",
authPane: {
authed: false,
captchaID: "",
},
browser: {
isVertical: false,
@ -98,6 +101,7 @@ export function mockState(): ICoreState {
paneNames: Set<string>(["settings", "login", "admin"]),
login: {
authed: false,
captchaID: "",
},
admin: {
users: Map<string, User>(),

View file

@ -10,6 +10,7 @@ import { Layouter } from "./layouter";
export interface Props {
authed: boolean;
captchaID: string;
update?: (updater: (prevState: ICoreState) => ICoreState) => void;
}
@ -23,8 +24,13 @@ export class Updater {
Updater.client = client;
};
static login = async (user: string, pwd: string): Promise<boolean> => {
const resp = await Updater.client.login(user, pwd);
static login = async (
user: string,
pwd: string,
captchaID: string,
captchaInput: string
): Promise<boolean> => {
const resp = await Updater.client.login(user, pwd, captchaID, captchaInput);
Updater.setAuthed(resp.status === 200);
return resp.status === 200;
};
@ -50,6 +56,15 @@ export class Updater {
Updater.props.authed = isAuthed;
};
static getCaptchaID = async (): Promise<boolean> => {
return Updater.client.getCaptchaID().then((resp) => {
if (resp.status === 200) {
Updater.props.captchaID = resp.data.id;
}
return resp.status === 200;
});
};
static setAuthPane = (preState: ICoreState): ICoreState => {
preState.panel.authPane = {
...preState.panel.authPane,
@ -62,6 +77,7 @@ export class Updater {
export interface State {
user: string;
pwd: string;
captchaInput: string;
}
export class AuthPane extends React.Component<Props, State, {}> {
@ -74,6 +90,7 @@ export class AuthPane extends React.Component<Props, State, {}> {
this.state = {
user: "",
pwd: "",
captchaInput: "",
};
this.initIsAuthed();
@ -87,6 +104,10 @@ export class AuthPane extends React.Component<Props, State, {}> {
this.setState({ pwd: ev.target.value });
};
changeCaptcha = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ captchaInput: ev.target.value });
};
initIsAuthed = () => {
Updater.initIsAuthed().then(() => {
this.update(Updater.setAuthPane);
@ -94,7 +115,12 @@ export class AuthPane extends React.Component<Props, State, {}> {
};
login = () => {
Updater.login(this.state.user, this.state.pwd)
Updater.login(
this.state.user,
this.state.pwd,
this.props.captchaID,
this.state.captchaInput
)
.then((ok: boolean) => {
if (ok) {
this.update(Updater.setAuthPane);
@ -129,33 +155,6 @@ export class AuthPane extends React.Component<Props, State, {}> {
};
render() {
const elements: Array<JSX.Element> = [
<input
name="user"
type="text"
onChange={this.changeUser}
value={this.state.user}
className="black0-font margin-t-m margin-b-m"
// style={{ width: "80%" }}
placeholder="user name"
/>,
<input
name="pwd"
type="password"
onChange={this.changePwd}
value={this.state.pwd}
className="black0-font margin-t-m margin-b-m"
// style={{ width: "80%" }}
placeholder="password"
/>,
<button
onClick={this.login}
className="green0-bg white-font margin-t-m margin-b-m"
>
Log in
</button>,
];
return (
<span>
<div
@ -163,8 +162,52 @@ export class AuthPane extends React.Component<Props, State, {}> {
style={{ display: this.props.authed ? "none" : "block" }}
>
<div className="padding-l">
{/* <h5 className="black-font">Login</h5> */}
<Layouter isHorizontal={false} elements={elements} />
<div className="flex-list-container">
<div className="flex-list-item-l">
<input
name="user"
type="text"
onChange={this.changeUser}
value={this.state.user}
className="black0-font margin-t-m margin-b-m margin-r-m"
placeholder="user name"
/>
<input
name="pwd"
type="password"
onChange={this.changePwd}
value={this.state.pwd}
className="black0-font margin-t-m margin-b-m"
placeholder="password"
/>
</div>
<div className="flex-list-item-r">
<button
onClick={this.login}
className="green0-bg white-font margin-t-m margin-b-m"
>
Log in
</button>
</div>
</div>
<div className="flex-list-container">
<div className="flex-list-item-l">
<input
name="captcha"
type="text"
onChange={this.changeCaptcha}
value={this.state.captchaInput}
className="black0-font margin-t-m margin-b-m margin-r-m"
placeholder="captcha"
/>
<img
src={`/v1/captchas/imgs?capid=${this.props.captchaID}`}
className="captcha"
/>
</div>
<div className="flex-list-item-l"></div>
</div>
</div>
</div>

View file

@ -3,7 +3,6 @@ import * as React from "react";
import { ICoreState } from "./core_state";
import { IUsersClient } from "../client";
import { AuthPane, Props as LoginProps } from "./pane_login";
import { Layouter } from "./layouter";
import { UsersClient } from "../client/users";
export interface Props {
@ -174,6 +173,7 @@ export class PaneSettings extends React.Component<Props, State, {}> {
<div className="flex-list-item-r">
<AuthPane
authed={this.props.login.authed}
captchaID={this.props.login.captchaID}
update={this.update}
/>
</div>

View file

@ -47,8 +47,7 @@ export class Updater {
return true;
}
return false;
}
};
static addUser = async (user: User): Promise<boolean> => {
const resp = await Updater.client.addUser(user.name, user.pwd, user.role);
@ -156,7 +155,11 @@ export class Panes extends React.Component<Props, State, {}> {
<PaneSettings login={this.props.login} update={this.props.update} />
),
login: (
<AuthPane authed={this.props.login.authed} update={this.props.update} />
<AuthPane
authed={this.props.login.authed}
captchaID={this.props.login.captchaID}
update={this.props.update}
/>
),
});

View file

@ -3,7 +3,6 @@ import * as React from "react";
import { ICoreState, BaseUpdater } from "./core_state";
import { Browser, Props as BrowserProps } from "./browser";
import { Props as PaneLoginProps } from "./pane_login";
import { Props as PaneAdminProps } from "./pane_admin";
import { Panes, Props as PanesProps, Updater as PanesUpdater } from "./panes";
export interface Props {
@ -100,7 +99,6 @@ export class RootFrame extends React.Component<Props, State, {}> {
<a href="https://github.com/ihexxa/quickshare">Quickshare</a> -
sharing in simple way.
</div>
</div>
</div>
);

View file

@ -6,6 +6,7 @@ import { ICoreState, init } from "./core_state";
import { RootFrame } from "./root_frame";
import { FilesClient } from "../client/files";
import { UsersClient } from "../client/users";
import { Updater as LoginPaneUpdater } from "./pane_login";
export interface Props {}
export interface State extends ICoreState {}
@ -20,6 +21,19 @@ export class StateMgr extends React.Component<Props, State, {}> {
initUpdaters = (state: ICoreState) => {
BrowserUpdater().init(state.panel.browser);
BrowserUpdater().setClients(new UsersClient(""), new FilesClient(""));
LoginPaneUpdater.init(state.panel.authPane);
LoginPaneUpdater.setClient(new UsersClient(""));
LoginPaneUpdater.getCaptchaID()
.then((ok: boolean) => {
if (!ok) {
alert("failed to get captcha id");
} else {
this.update(LoginPaneUpdater.setAuthPane);
console.log(LoginPaneUpdater)
}
});
BrowserUpdater()
.setHomeItems()
.then(() => {

View file

@ -0,0 +1,42 @@
package multiusers
import (
"bytes"
"errors"
"github.com/dchest/captcha"
"github.com/gin-gonic/gin"
q "github.com/ihexxa/quickshare/src/handlers"
)
type GetCaptchaIDResp struct {
CaptchaID string `json:"id"`
}
func (h *MultiUsersSvc) GetCaptchaID(c *gin.Context) {
captchaID := captcha.New()
c.JSON(200, &GetCaptchaIDResp{CaptchaID: captchaID})
}
// path: /captchas/imgs?id=xxx
func (h *MultiUsersSvc) GetCaptchaImg(c *gin.Context) {
captchaID := c.Query(q.CaptchaIDParam)
if captchaID == "" {
c.JSON(q.ErrResp(c, 400, errors.New("empty captcha ID")))
return
}
capWidth := h.cfg.IntOr("Users.CaptchaWidth", 256)
capHeight := h.cfg.IntOr("Users.CaptchaHeight", 64)
// TODO: improve performance
buf := new(bytes.Buffer)
err := captcha.WriteImage(buf, captchaID, capWidth, capHeight)
if err != nil {
c.JSON(q.ErrResp(c, 500, err))
return
}
c.Data(200, "image/png", buf.Bytes())
}

View file

@ -7,6 +7,7 @@ import (
"strconv"
"time"
"github.com/dchest/captcha"
"github.com/gin-gonic/gin"
"github.com/ihexxa/gocfg"
"golang.org/x/crypto/bcrypt"
@ -61,6 +62,8 @@ func NewMultiUsersSvc(cfg gocfg.ICfg, deps *depidx.Deps) (*MultiUsersSvc, error)
apiRuleCname(userstore.AdminRole, "DELETE", "/v1/fs/uploadings"): true,
apiRuleCname(userstore.AdminRole, "GET", "/v1/fs/metadata"): true,
apiRuleCname(userstore.AdminRole, "OPTIONS", "/v1/settings/health"): true,
apiRuleCname(userstore.AdminRole, "GET", "/v1/captchas/"): true,
apiRuleCname(userstore.AdminRole, "GET", "/v1/captchas/imgs"): true,
// user rules
apiRuleCname(userstore.UserRole, "GET", "/"): true,
apiRuleCname(userstore.UserRole, "GET", publicPath): true,
@ -82,6 +85,8 @@ func NewMultiUsersSvc(cfg gocfg.ICfg, deps *depidx.Deps) (*MultiUsersSvc, error)
apiRuleCname(userstore.UserRole, "DELETE", "/v1/fs/uploadings"): true,
apiRuleCname(userstore.UserRole, "GET", "/v1/fs/metadata"): true,
apiRuleCname(userstore.UserRole, "OPTIONS", "/v1/settings/health"): true,
apiRuleCname(userstore.UserRole, "GET", "/v1/captchas/"): true,
apiRuleCname(userstore.UserRole, "GET", "/v1/captchas/imgs"): true,
// visitor rules
apiRuleCname(userstore.VisitorRole, "GET", "/"): true,
apiRuleCname(userstore.VisitorRole, "GET", publicPath): true,
@ -90,6 +95,8 @@ func NewMultiUsersSvc(cfg gocfg.ICfg, deps *depidx.Deps) (*MultiUsersSvc, error)
apiRuleCname(userstore.VisitorRole, "GET", "/v1/users/self"): true,
apiRuleCname(userstore.VisitorRole, "GET", "/v1/fs/files"): true,
apiRuleCname(userstore.VisitorRole, "OPTIONS", "/v1/settings/health"): true,
apiRuleCname(userstore.VisitorRole, "GET", "/v1/captchas/"): true,
apiRuleCname(userstore.VisitorRole, "GET", "/v1/captchas/imgs"): true,
}
return &MultiUsersSvc{
@ -122,8 +129,10 @@ func (h *MultiUsersSvc) IsInited() bool {
}
type LoginReq struct {
User string `json:"user"`
Pwd string `json:"pwd"`
User string `json:"user"`
Pwd string `json:"pwd"`
CaptchaID string `json:"captchaId"`
CaptchaInput string `json:"captchaInput"`
}
func (h *MultiUsersSvc) Login(c *gin.Context) {
@ -133,6 +142,15 @@ func (h *MultiUsersSvc) Login(c *gin.Context) {
return
}
// TODO: add rate limiter for verifying
captchaEnabled := h.cfg.BoolOr("Users.CaptchaEnabled", true)
if captchaEnabled {
if !captcha.VerifyString(req.CaptchaID, req.CaptchaInput) {
c.JSON(q.ErrResp(c, 403, errors.New("login failed")))
return
}
}
user, err := h.deps.Users().GetUserByName(req.User)
if err != nil {
c.JSON(q.ErrResp(c, 500, err))

View file

@ -15,14 +15,15 @@ var (
FsDir = "files"
FsRootDir = "files"
UserIDParam = "uid"
UserParam = "user"
PwdParam = "pwd"
NewPwdParam = "newpwd"
RoleParam = "role"
ExpireParam = "expire"
TokenCookie = "tk"
LastID = "lid"
UserIDParam = "uid"
UserParam = "user"
PwdParam = "pwd"
NewPwdParam = "newpwd"
RoleParam = "role"
ExpireParam = "expire"
CaptchaIDParam = "capid"
TokenCookie = "tk"
LastID = "lid"
ErrAccessDenied = errors.New("access denied")
ErrUnauthorized = errors.New("unauthorized")

View file

@ -17,6 +17,9 @@ type UsersCfg struct {
CookieHttpOnly bool `json:"cookieHttpOnly" yaml:"cookieHttpOnly"`
MinUserNameLen int `json:"minUserNameLen" yaml:"minUserNameLen"`
MinPwdLen int `json:"minPwdLen" yaml:"minPwdLen"`
CaptchaWidth int `json:"captchaWidth" yaml:"captchaWidth"`
CaptchaHeight int `json:"captchaHeight" yaml:"captchaHeight"`
CaptchaEnabled bool `json:"captchaEnabled" yaml:"captchaEnabled"`
}
type Secrets struct {
@ -60,6 +63,9 @@ func DefaultConfig() (string, error) {
CookieHttpOnly: true,
MinUserNameLen: 4,
MinPwdLen: 6,
CaptchaWidth: 256,
CaptchaHeight: 60,
CaptchaEnabled: true,
},
Secrets: &Secrets{
TokenSecret: "",

View file

@ -189,6 +189,10 @@ func initHandlers(router *gin.Engine, cfg gocfg.ICfg, deps *depidx.Deps) (*gin.E
rolesAPI.DELETE("/", userHdrs.DelRole)
rolesAPI.GET("/list", userHdrs.ListRoles)
captchaAPI := v1.Group("/captchas")
captchaAPI.GET("/", userHdrs.GetCaptchaID)
captchaAPI.GET("/imgs", userHdrs.GetCaptchaImg)
filesAPI := v1.Group("/fs")
filesAPI.POST("/files", fileHdrs.Create)
filesAPI.DELETE("/files", fileHdrs.Delete)

View file

@ -18,7 +18,8 @@ func TestConcurrency(t *testing.T) {
"users": {
"enableAuth": true,
"minUserNameLen": 2,
"minPwdLen": 4
"minPwdLen": 4,
"captchaEnabled": false
},
"server": {
"debug": true

View file

@ -21,7 +21,8 @@ func TestFileHandlers(t *testing.T) {
"users": {
"enableAuth": true,
"minUserNameLen": 2,
"minPwdLen": 4
"minPwdLen": 4,
"captchaEnabled": false
},
"server": {
"debug": true

View file

@ -19,7 +19,8 @@ func TestUsersHandlers(t *testing.T) {
"users": {
"enableAuth": true,
"minUserNameLen": 2,
"minPwdLen": 4
"minPwdLen": 4,
"captchaEnabled": false
},
"server": {
"debug": true