added magic access link

This commit is contained in:
Roland Osborne 2022-06-08 01:25:41 -07:00
parent 8ff747f90f
commit 74244b920d
17 changed files with 309 additions and 39 deletions

View File

@ -236,6 +236,33 @@ paths:
description: account not found description: account not found
'500': '500':
description: internal server error description: internal server error
/admin/accounts/{accountId}/auth:
post:
tags:
- account
description: Generate token to reset authentication.
operationId: add-account-authentication
parameters:
- name: token
in: query
description: token for admin access
required: true
schema:
type: string
responses:
'201':
description: generated
content:
application/json:
schema:
type: string
'401':
description: invalid password
'500':
description: internal server error
/admin/accounts/{accountId}/status:
put: put:
tags: tags:
- admin - admin
@ -581,24 +608,6 @@ paths:
description: internal server error description: internal server error
/account/auth: /account/auth:
post:
tags:
- account
description: Generate token to reset authentication. Access granted to account's login and password.
operationId: add-account-authentication
security:
- basicAuth: []
responses:
'201':
description: generated
content:
application/json:
schema:
type: string
'401':
description: invalid password
'500':
description: internal server error
put: put:
tags: tags:
- account - account

View File

@ -0,0 +1,49 @@
package databag
import (
"net/http"
"time"
"strconv"
"encoding/hex"
"databag/internal/store"
"github.com/theckman/go-securerandom"
"github.com/gorilla/mux"
)
func AddNodeAccountAccess(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
accountId, res := strconv.ParseUint(params["accountId"], 10, 32)
if res != nil {
ErrResponse(w, http.StatusBadRequest, res)
return
}
if code, err := ParamAdminToken(r); err != nil {
ErrResponse(w, code, err)
return
}
data, ret := securerandom.Bytes(APP_RESETSIZE)
if ret != nil {
ErrResponse(w, http.StatusInternalServerError, ret)
return
}
token := hex.EncodeToString(data)
accountToken := store.AccountToken{
AccountID: uint(accountId),
TokenType: APP_TOKENRESET,
Token: token,
Expires: time.Now().Unix() + APP_RESETEXPIRE,
}
if err := store.DB.Create(&accountToken).Error; err != nil {
ErrResponse(w, http.StatusInternalServerError, err)
return
}
WriteResponse(w, token)
}

View File

@ -0,0 +1,71 @@
package databag
import (
"errors"
"net/http"
"encoding/hex"
"gorm.io/gorm"
"databag/internal/store"
"github.com/theckman/go-securerandom"
)
func SetAccountAccess(w http.ResponseWriter, r *http.Request) {
token, _, res := AccessToken(r)
if res != nil || token.TokenType != APP_TOKENRESET {
ErrResponse(w, http.StatusUnauthorized, res)
return
}
if token.Account == nil {
ErrResponse(w, http.StatusUnauthorized, errors.New("invalid reset token"))
return
}
account := token.Account;
// parse app data
var appData AppData
if err := ParseRequest(r, w, &appData); err != nil {
ErrResponse(w, http.StatusBadRequest, err)
return
}
// gernate app token
data, err := securerandom.Bytes(APP_TOKENSIZE)
if err != nil {
ErrResponse(w, http.StatusInternalServerError, err)
return
}
access := hex.EncodeToString(data)
// create app entry
app := store.App {
AccountID: account.Guid,
Name: appData.Name,
Description: appData.Description,
Image: appData.Image,
Url: appData.Url,
Token: access,
};
// save app and delete token
err = store.DB.Transaction(func(tx *gorm.DB) error {
if res := tx.Create(&app).Error; res != nil {
return res;
}
if res := tx.Save(token.Account).Error; res != nil {
return res
}
if res := tx.Delete(token).Error; res != nil {
return res
}
return nil;
});
if err != nil {
ErrResponse(w, http.StatusInternalServerError, err)
return
}
WriteResponse(w, account.Guid + "." + access)
}

View File

@ -50,6 +50,25 @@ func BearerAccountToken(r *http.Request) (*store.AccountToken, error) {
return &accountToken, nil return &accountToken, nil
} }
func AccessToken(r *http.Request) (*store.AccountToken, int, error) {
// parse authentication token
token := r.FormValue("token")
if token == "" {
return nil, http.StatusUnauthorized, errors.New("token not set");
}
// find token record
var accountToken store.AccountToken
if err := store.DB.Preload("Account").Where("token = ?", token).First(&accountToken).Error; err != nil {
return nil, http.StatusUnauthorized, err
}
if accountToken.Expires < time.Now().Unix() {
return nil, http.StatusUnauthorized, errors.New("expired token")
}
return &accountToken, http.StatusOK, nil
}
func ParamAdminToken(r *http.Request) (int, error) { func ParamAdminToken(r *http.Request) (int, error) {
// parse authentication token // parse authentication token

View File

@ -144,6 +144,13 @@ var routes = Routes{
RemoveAccountApp, RemoveAccountApp,
}, },
Route{
"SetAccountAccess",
strings.ToUpper("Put"),
"/account/access",
SetAccountAccess,
},
Route{ Route{
"SetAccountAuthentication", "SetAccountAuthentication",
strings.ToUpper("Put"), strings.ToUpper("Put"),
@ -200,6 +207,13 @@ var routes = Routes{
SetNodeAccountStatus, SetNodeAccountStatus,
}, },
Route{
"AddNodeAccountAccess",
strings.ToUpper("Post"),
"/admin/accounts/{accountId}/auth",
AddNodeAccountAccess,
},
Route{ Route{
"GetNodeAccounts", "GetNodeAccounts",
strings.ToUpper("Get"), strings.ToUpper("Get"),

View File

@ -1,30 +1,38 @@
import { Avatar } from 'avatar/Avatar'; import { Avatar } from 'avatar/Avatar';
import { AccountItemWrapper, DeleteButton, EnableButton, DisableButton, ResetButton } from './AccountItem.styled'; import { AccountItemWrapper, AccessLayout, DeleteButton, EnableButton, DisableButton, ResetButton } from './AccountItem.styled';
import { useAccountItem } from './useAccountItem.hook'; import { useAccountItem } from './useAccountItem.hook';
import { UserDeleteOutlined, UnlockOutlined, CloseCircleOutlined, CheckCircleOutlined } from '@ant-design/icons'; import { CopyOutlined, UserDeleteOutlined, UnlockOutlined, CloseCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { Tooltip } from 'antd'; import { Modal, Tooltip, Button } from 'antd';
export function AccountItem({ token, item }) { export function AccountItem({ token, item, remove }) {
const { state, actions } = useAccountItem(token, item); const { state, actions } = useAccountItem(token, item, remove);
const onClipboard = (value) => {
navigator.clipboard.writeText(value);
};
const Enable = () => { const Enable = () => {
if (state.disabled) { if (state.disabled) {
return ( return (
<Tooltip placement="topLeft" title="Enable Account"> <Tooltip placement="topLeft" title="Enable Account">
<EnableButton type="text" size="large" icon={<CheckCircleOutlined />} <EnableButton type="text" size="large" icon={<CheckCircleOutlined />}
onClick={() => actions.setStatus(false)}></EnableButton> loading={state.statusBusy} onClick={() => actions.setStatus(false)}></EnableButton>
</Tooltip> </Tooltip>
) )
} }
return ( return (
<Tooltip placement="topLeft" title="Disable Account"> <Tooltip placement="topLeft" title="Disable Account">
<DisableButton type="text" size="large" icon={<CloseCircleOutlined />} <DisableButton type="text" size="large" icon={<CloseCircleOutlined />}
onClick={() => actions.setStatus(true)}></DisableButton> loading={state.statusBusy} onClick={() => actions.setStatus(true)}></DisableButton>
</Tooltip> </Tooltip>
) )
} }
const accessLink = () => {
return window.location.origin + '/#/login?access=' + state.accessToken;
};
return ( return (
<AccountItemWrapper> <AccountItemWrapper>
<div class="avatar"> <div class="avatar">
@ -35,14 +43,26 @@ export function AccountItem({ token, item }) {
<div class="guid">{ state.guid }</div> <div class="guid">{ state.guid }</div>
</div> </div>
<div class="control"> <div class="control">
<Tooltip placement="topLeft" title="Reset Password"> <Tooltip placement="topLeft" title="Account Login Link">
<ResetButton type="text" size="large" icon={<UnlockOutlined />}></ResetButton> <ResetButton type="text" size="large" icon={<UnlockOutlined />}
onClick={() => actions.setAccessLink()}></ResetButton>
</Tooltip> </Tooltip>
<Enable /> <Enable />
<Tooltip placement="topLeft" title="Delete Account"> <Tooltip placement="topLeft" title="Delete Account">
<DeleteButton type="text" size="large" icon={<UserDeleteOutlined />}></DeleteButton> <DeleteButton type="text" size="large" icon={<UserDeleteOutlined />}
loading={state.removeBusy} onClick={() => actions.remove()}></DeleteButton>
</Tooltip> </Tooltip>
</div> </div>
<Modal title="Access Link" visible={state.showAccess} centered width="fitContent"
footer={[ <Button type="primary" onClick={() => actions.setShowAccess(false)}>OK</Button> ]}
onCancel={() => actions.setShowAccess(false)}>
<AccessLayout>
<div>{accessLink()}</div>
<Button icon={<CopyOutlined />} size="small"
onClick={() => onClipboard(accessLink())}
/>
</AccessLayout>
</Modal>
</AccountItemWrapper> </AccountItemWrapper>
); );
} }

View File

@ -1,4 +1,4 @@
import { Button } from 'antd'; import { Space, Button } from 'antd';
import styled from 'styled-components'; import styled from 'styled-components';
export const AccountItemWrapper = styled.div` export const AccountItemWrapper = styled.div`
@ -74,3 +74,7 @@ export const ResetButton = styled(Button)`
export const DeleteButton = styled(Button)` export const DeleteButton = styled(Button)`
color: red; color: red;
` `
export const AccessLayout = styled(Space)`
white-space: nowrap;
`

View File

@ -1,11 +1,14 @@
import { useContext, useState, useEffect } from 'react'; import { useContext, useState, useEffect } from 'react';
import { getAccountImageUrl } from 'api/getAccountImageUrl'; import { getAccountImageUrl } from 'api/getAccountImageUrl';
import { setAccountStatus } from 'api/setAccountStatus'; import { setAccountStatus } from 'api/setAccountStatus';
import { addAccountAccess } from 'api/addAccountAccess';
export function useAccountItem(token, item) { export function useAccountItem(token, item, remove) {
const [state, setState] = useState({ const [state, setState] = useState({
statusBusy: false, statusBusy: false,
removeBusy: false,
showAccess: false,
}); });
const updateState = (value) => { const updateState = (value) => {
@ -25,6 +28,26 @@ export function useAccountItem(token, item) {
}, [token, item]); }, [token, item]);
const actions = { const actions = {
setAccessLink: async () => {
let access = await addAccountAccess(token, item.accountId);
updateState({ accessToken: access, showAccess: true });
},
setShowAccess: (showAccess) => {
updateState({ showAccess });
},
remove: async () => {
if (!state.removeBusy) {
updateState({ removeBusy: true });
try {
await remove(state.accountId);
}
catch(err) {
console.log(err);
window.alert(err);
}
updateState({ removeBusy: false });
}
},
setStatus: async (disabled) => { setStatus: async (disabled) => {
if (!state.statusBusy) { if (!state.statusBusy) {
updateState({ statusBusy: true }); updateState({ statusBusy: true });

View File

@ -33,7 +33,7 @@ export function Dashboard({ token, config, logout }) {
</Tooltip> </Tooltip>
</div> </div>
<div class="add"> <div class="add">
<Tooltip placement="topRight" title="Add Account"> <Tooltip placement="topRight" title="Create Account Link">
<AddButton type="text" size="large" icon={<UserAddOutlined />}></AddButton> <AddButton type="text" size="large" icon={<UserAddOutlined />}></AddButton>
</Tooltip> </Tooltip>
</div> </div>
@ -45,7 +45,8 @@ export function Dashboard({ token, config, logout }) {
itemLayout="horizontal" itemLayout="horizontal"
dataSource={state.accounts} dataSource={state.accounts}
loading={state.loading} loading={state.loading}
renderItem={item => (<AccountItem token={token} item={item} />)} renderItem={item => (<AccountItem token={token} item={item}
remove={actions.removeAccount}/>)}
/> />
</div> </div>
</div> </div>

View File

@ -13,7 +13,9 @@ export const DashboardWrapper = styled.div`
background-color: #ffffff; background-color: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 16px; padding-top: 16px;
padding-left: 16px;
padding-right: 16px;
border-radius: 4px; border-radius: 4px;
min-width: 800px; min-width: 800px;
max-width: 900px; max-width: 900px;
@ -25,13 +27,15 @@ export const DashboardWrapper = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
font-size: 20px; font-size: 20px;
border-bottom: 1px solid #444444; border-bottom: 1px solid #aaaaaa;
} }
.body { .body {
padding-top: 8px; padding-top: 8px;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
border-bottom: 1px solid #aaaaaa;
margin-bottom: 16px;
} }
.label { .label {

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { setNodeConfig } from 'api/setNodeConfig'; import { setNodeConfig } from 'api/setNodeConfig';
import { getNodeAccounts } from 'api/getNodeAccounts'; import { getNodeAccounts } from 'api/getNodeAccounts';
import { removeAccount } from 'api/removeAccount';
export function useDashboard(token, config) { export function useDashboard(token, config) {
@ -18,6 +19,10 @@ export function useDashboard(token, config) {
} }
const actions = { const actions = {
removeAccount: async (accountId) => {
await removeAccount(token, accountId);
actions.getAccounts();
},
setHost: (value) => { setHost: (value) => {
updateState({ host: value }); updateState({ host: value });
}, },

View File

@ -1,6 +1,6 @@
import { useContext, useState, useEffect } from 'react'; import { useContext, useState, useEffect } from 'react';
import { AppContext } from 'context/AppContext'; import { AppContext } from 'context/AppContext';
import { useNavigate } from "react-router-dom"; import { useNavigate, useLocation, useParams } from "react-router-dom";
export function useLogin() { export function useLogin() {
@ -12,6 +12,7 @@ export function useLogin() {
}); });
const navigate = useNavigate(); const navigate = useNavigate();
const { search } = useLocation();
const app = useContext(AppContext); const app = useContext(AppContext);
const actions = { const actions = {
@ -42,6 +43,16 @@ export function useLogin() {
actions.updateState({ spinning: false }) actions.updateState({ spinning: false })
} }
}, },
onAccess: async (token) => {
actions.updateState({ spinning: true })
try {
await app.actions.access(token)
}
catch (err) {
window.alert(err);
}
actions.updateState({ spinning: false })
},
onCreate: () => { onCreate: () => {
navigate('/create') navigate('/create')
}, },
@ -56,8 +67,12 @@ export function useLogin() {
if (app.state.access === 'user') { if (app.state.access === 'user') {
navigate('/user') navigate('/user')
} }
if (app.state.access === 'admin') { else {
navigate('/admin') let params = new URLSearchParams(search);
let token = params.get("access");
if (token) {
actions.onAccess(token);
}
} }
} }
if (app.actions && app.actions.available) { if (app.actions && app.actions.available) {

View File

@ -0,0 +1,8 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function addAccountAccess(token, accountId) {
let access = await fetchWithTimeout(`/admin/accounts/${accountId}/auth?token=${token}`, { method: 'POST' })
checkResponse(access);
return await access.json()
}

View File

@ -0,0 +1,7 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function removeAccount(token, accountId) {
let res = await fetchWithTimeout(`/admin/accounts/${accountId}?token=${token}`, { method: 'DELETE' })
checkResponse(res);
}

View File

@ -0,0 +1,9 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function setAccountAccess(token) {
let app = { Name: "indicom", Description: "decentralized communication" }
let access = await fetchWithTimeout(`/account/access?token=${token}`, { method: 'PUT', body: JSON.stringify(app) })
checkResponse(access)
return await access.json()
}

View File

@ -1,5 +1,7 @@
import { useEffect, useState, useRef, useContext } from 'react'; import { useEffect, useState, useRef, useContext } from 'react';
import { useNavigate, useLocation, useParams } from "react-router-dom";
import { getAvailable, getUsername, setLogin, createAccount } from './fetchUtil'; import { getAvailable, getUsername, setLogin, createAccount } from './fetchUtil';
import { setAccountAccess } from 'api/setAccountAccess';
import { AccountContext } from './AccountContext'; import { AccountContext } from './AccountContext';
import { ProfileContext } from './ProfileContext'; import { ProfileContext } from './ProfileContext';
import { ArticleContext } from './ArticleContext'; import { ArticleContext } from './ArticleContext';
@ -22,6 +24,13 @@ async function appLogin(username, password, updateState, setWebsocket) {
localStorage.setItem("session", JSON.stringify({ token: access, access: 'user' })); localStorage.setItem("session", JSON.stringify({ token: access, access: 'user' }));
} }
async function appAccess(token, updateState, setWebsocket) {
let access = await setAccountAccess(token)
updateState({ token: access, access: 'user' });
setWebsocket(access)
localStorage.setItem("session", JSON.stringify({ token: access, access: 'user' }));
}
function appLogout(updateState, clearWebsocket) { function appLogout(updateState, clearWebsocket) {
updateState({ token: null, access: null }); updateState({ token: null, access: null });
clearWebsocket() clearWebsocket()
@ -80,6 +89,9 @@ export function useAppContext() {
} }
const accessActions = { const accessActions = {
access: async (token) => {
await appAccess(token, updateState, setWebsocket)
},
login: async (username, password) => { login: async (username, password) => {
await appLogin(username, password, updateState, setWebsocket) await appLogin(username, password, updateState, setWebsocket)
}, },

View File

@ -19,10 +19,10 @@ export function useGroupContext() {
let delta = await getGroups(access.current, revision.current); let delta = await getGroups(access.current, revision.current);
for (let group of delta) { for (let group of delta) {
if (group.data) { if (group.data) {
groups.set(group.id, group); groups.current.set(group.id, group);
} }
else { else {
groups.delete(group.id); groups.current.delete(group.id);
} }
} }
} }