EIP-712 signatures for login & signup (#189)

* Signup requires valid EIP-712 signature. Refactor some auth reducer nomenclature for consistency.

* Add auth endpoint for logging in that checks for valid signature, like create user.

* Fix tests, move dummy data into test_data.py.

* No strict slashes.
This commit is contained in:
William O'Beirne 2018-11-07 14:08:42 -05:00 committed by GitHub
parent d8eba48847
commit 250d5fb7a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 327 additions and 215 deletions

View File

@ -10,6 +10,7 @@ from grant.extensions import bcrypt, migrate, db, ma, mail
def create_app(config_object="grant.settings"):
app = Flask(__name__.split(".")[0])
app.config.from_object(config_object)
app.url_map.strict_slashes = False
register_extensions(app)
register_blueprints(app)
register_shellcontext(app)

View File

@ -4,7 +4,7 @@ from flask_yoloapi import endpoint, parameter
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
from grant.proposal.models import Proposal, proposal_team
from grant.utils.auth import requires_sm
from grant.utils.auth import requires_sm, verify_signed_auth, BadSignatureException
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
@ -50,12 +50,34 @@ def get_user(user_identity):
parameter('emailAddress', type=str, required=True),
parameter('displayName', type=str, required=True),
parameter('title', type=str, required=True),
parameter('signedMessage', type=str, required=True),
parameter('rawTypedData', type=str, required=True)
)
def create_user(account_address, email_address, display_name, title):
def create_user(
account_address,
email_address,
display_name,
title,
signed_message,
raw_typed_data
):
existing_user = User.get_by_email_or_account_address(email_address=email_address, account_address=account_address)
if existing_user:
return {"message": "User with that address or email already exists"}, 409
# Handle signature
try:
sig_address = verify_signed_auth(signed_message, raw_typed_data)
if sig_address.lower() != account_address.lower():
return {
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
sig_address=sig_address,
account_address=account_address
)
}, 400
except BadSignatureException:
return {"message": "Invalid message signature"}, 400
# TODO: Handle avatar & social stuff too
user = User.create(
account_address=account_address,
@ -66,6 +88,30 @@ def create_user(account_address, email_address, display_name, title):
result = user_schema.dump(user)
return result
@blueprint.route("/auth", methods=["POST"])
@endpoint.api(
parameter('accountAddress', type=str, required=True),
parameter('signedMessage', type=str, required=True),
parameter('rawTypedData', type=str, required=True)
)
def auth_user(account_address, signed_message, raw_typed_data):
existing_user = User.get_by_email_or_account_address(account_address=account_address)
if not existing_user:
return {"message": "No user exists with that address"}, 400
try:
sig_address = verify_signed_auth(signed_message, raw_typed_data)
if sig_address.lower() != account_address.lower():
return {
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
sig_address=sig_address,
account_address=account_address
)
}, 400
except BadSignatureException:
return {"message": "Invalid message signature"}, 400
return user_schema.dump(existing_user)
@blueprint.route("/<user_identity>", methods=["PUT"])
@endpoint.api(

View File

@ -31,6 +31,25 @@ def verify_token(token):
return data
# Custom exception for bad auth
class BadSignatureException(Exception):
pass
def verify_signed_auth(signature, typed_data):
loaded_typed_data = ast.literal_eval(typed_data)
url = AUTH_URL + "/message/recover"
payload = json.dumps({"sig": signature, "data": loaded_typed_data})
headers = {'content-type': 'application/json'}
response = requests.request("POST", url, data=payload, headers=headers)
json_response = response.json()
recovered_address = json_response.get('recoveredAddress')
if not recovered_address:
raise BadSignatureException("Authorization signature is invalid")
return recovered_address
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
@ -50,24 +69,19 @@ def requires_auth(f):
def requires_sm(f):
@wraps(f)
def decorated(*args, **kwargs):
typed_data = request.headers.get('RawTypedData', None)
signature = request.headers.get('MsgSignature', None)
typed_data = request.headers.get('RawTypedData', None)
if typed_data and signature:
loaded_typed_data = ast.literal_eval(typed_data)
url = AUTH_URL + "/message/recover"
payload = json.dumps({"sig": signature, "data": loaded_typed_data})
headers = {'content-type': 'application/json'}
response = requests.request("POST", url, data=payload, headers=headers)
json_response = response.json()
recovered_address = json_response.get('recoveredAddress')
auth_address = None
try:
auth_address = verify_signed_auth(signature, typed_data)
except BadSignatureException:
return jsonify(message="Invalid auth message signature"), 401
if not recovered_address:
return jsonify(message="No user exists with address: {}".format(recovered_address)), 401
user = User.get_by_email_or_account_address(account_address=recovered_address)
user = User.get_by_email_or_account_address(account_address=auth_address)
if not user:
return jsonify(message="No user exists with address: {}".format(recovered_address)), 401
return jsonify(message="No user exists with address: {}".format(auth_address)), 401
g.current_user = user
return f(*args, **kwargs)

View File

@ -0,0 +1,82 @@
import json
import random
from grant.proposal.models import CATEGORIES
message = {
"sig": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c",
"data": {
"types": {
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"}
],
"Person": [
{"name": "name", "type": "string"},
{"name": "wallet", "type": "address"}
],
"Mail": [
{"name": "from", "type": "Person"},
{"name": "to", "type": "Person"},
{"name": "contents", "type": "string"}
]
},
"primaryType": "Mail",
"domain": {
"name": "Ether Mail",
"version": "1",
"chainId": 1,
"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
},
"message": {
"from": {
"name": "Cow",
"wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"
},
"to": {
"name": "Bob",
"wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"
},
"contents": "Hello, Bob!"
}
}
}
user = {
"accountAddress": '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826',
"displayName": 'Groot',
"emailAddress": 'iam@groot.com',
"title": 'I am Groot!',
"avatar": {
"link": 'https://avatars2.githubusercontent.com/u/1393943?s=400&v=4'
},
"socialMedias": [
{
"link": 'https://github.com/groot'
}
],
"signedMessage": message["sig"],
"rawTypedData": json.dumps(message["data"])
}
team = [user]
milestones = [
{
"title": "All the money straightaway",
"description": "cool stuff with it",
"date": "June 2019",
"payoutPercent": "100",
"immediatePayout": False
}
]
proposal = {
"team": team,
"crowdFundContractAddress": "0x20000",
"content": "## My Proposal",
"title": "Give Me Money",
"milestones": milestones,
"category": random.choice(CATEGORIES)
}

View File

@ -1,39 +1,7 @@
import json
from ..config import BaseTestConfig
account_address = '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826'
message = {
"sig": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c",
"data": {"types": {"EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"}],
"Person": [{"name": "name", "type": "string"}, {"name": "wallet", "type": "address"}],
"Mail": [{"name": "from", "type": "Person"}, {"name": "to", "type": "Person"},
{"name": "contents", "type": "string"}]}, "primaryType": "Mail",
"domain": {"name": "Ether Mail", "version": "1", "chainId": 1,
"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},
"message": {"from": {"name": "Cow", "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},
"to": {"name": "Bob", "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},
"contents": "Hello, Bob!"}}
}
user = {
"accountAddress": account_address,
"displayName": 'Groot',
"emailAddress": 'iam@groot.com',
"title": 'I am Groot!',
"avatar": {
"link": 'https://avatars2.githubusercontent.com/u/1393943?s=400&v=4'
},
"socialMedias": [
{
"link": 'https://github.com/groot'
}
]
}
from ..test_data import user, message
class TestRequiredSignedMessageDecorator(BaseTestConfig):

View File

@ -1,49 +1,12 @@
import copy
import json
import random
from grant.proposal.models import CATEGORIES
from grant.proposal.models import Proposal
from grant.user.models import User
from ..config import BaseTestConfig
from ..test_data import team, proposal
from mock import patch
milestones = [
{
"title": "All the money straightaway",
"description": "cool stuff with it",
"date": "June 2019",
"payoutPercent": "100",
"immediatePayout": False
}
]
team = [
{
"accountAddress": "0x1",
"displayName": 'Groot',
"emailAddress": 'iam@groot.com',
"title": 'I am Groot!',
"avatar": {
"link": 'https://avatars2.githubusercontent.com/u/1393943?s=400&v=4'
},
"socialMedias": [
{
"link": 'https://github.com/groot'
}
]
}
]
proposal = {
"team": team,
"crowdFundContractAddress": "0x20000",
"content": "## My Proposal",
"title": "Give Me Money",
"milestones": milestones,
"category": random.choice(CATEGORIES)
}
class TestAPI(BaseTestConfig):
def test_create_new_user_via_proposal_by_account_address(self):

View File

@ -63,9 +63,21 @@ export function createUser(payload: {
emailAddress: string;
displayName: string;
title: string;
token: string;
signedMessage: string;
rawTypedData: string;
}): Promise<{ data: TeamMember }> {
return axios.post(`/api/v1/users/`, payload).then(res => {
return axios.post('/api/v1/users', payload).then(res => {
res.data = formatTeamMemberFromGet(res.data);
return res;
});
}
export function authUser(payload: {
accountAddress: string;
signedMessage: string;
rawTypedData: string;
}): Promise<{ data: TeamMember }> {
return axios.post('/api/v1/users/auth', payload).then(res => {
res.data = formatTeamMemberFromGet(res.data);
return res;
});

View File

@ -5,4 +5,23 @@ const instance = axios.create({
headers: {},
});
instance.interceptors.response.use(
// Do nothing to responses
res => res,
// Try to parse error message if possible
err => {
if (err.response && err.response.data) {
// Our backend's handled error responses
if (err.response.data.message) {
err.message = err.response.data.message;
}
// Some flask middlewares return error data like this
if (err.response.data.data) {
err.message = err.response.data.data;
}
}
return Promise.reject(err);
},
);
export default instance;

View File

@ -1,7 +1,9 @@
@max-width: 460px;
.SignIn {
&-container {
width: 100%;
max-width: 460px;
max-width: @max-width;
margin: 0 auto;
padding: 1rem;
box-shadow: 0 1px 2px rgba(#000, 0.2);
@ -44,4 +46,9 @@
font-size: 0.8rem;
text-align: center;
}
&-error {
max-width: @max-width;
margin: 1rem auto 0;
}
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import { connect } from 'react-redux';
import { Button } from 'antd';
import { Button, Alert } from 'antd';
import { authActions } from 'modules/auth';
import { TeamMember } from 'types';
import { AppState } from 'store/reducers';
@ -29,7 +29,7 @@ type Props = StateProps & DispatchProps & OwnProps;
class SignIn extends React.Component<Props> {
render() {
const { user } = this.props;
const { user, authUserError } = this.props;
return (
<div className="SignIn">
<div className="SignIn-container">
@ -48,6 +48,16 @@ class SignIn extends React.Component<Props> {
</Button>
</div>
{authUserError && (
<Alert
className="SignIn-error"
type="error"
message="Failed to sign in"
description={authUserError}
showIcon
/>
)}
{/*
Temporarily only supporting web3, so there are no other identites
<p className="SignIn-back">

View File

@ -3,34 +3,46 @@ import { Dispatch } from 'redux';
import { sleep } from 'utils/helpers';
import { generateAuthSignatureData } from 'utils/auth';
import { AppState } from 'store/reducers';
import { createUser as apiCreateUser, getUser as apiGetUser } from 'api/api';
import {
createUser as apiCreateUser,
getUser as apiGetUser,
authUser as apiAuthUser,
} from 'api/api';
import { signData } from 'modules/web3/actions';
import { AuthSignatureData } from 'types';
type GetState = () => AppState;
const getAuthToken = (address: string, dispatch: Dispatch<any>) => {
const getAuthSignature = (
address: string,
dispatch: Dispatch<any>,
): Promise<AuthSignatureData> => {
const sigData = generateAuthSignatureData(address);
return (dispatch(
signData(sigData.data, sigData.types, sigData.primaryType),
) as any) as string;
) as any) as Promise<AuthSignatureData>;
};
// Auth from previous state
export function authUser(address: string, signature?: Falsy | string) {
// Auth from previous state, or request signature with new auth
export function authUser(address: string, authSignature?: Falsy | AuthSignatureData) {
return async (dispatch: Dispatch<any>) => {
dispatch({ type: types.AUTH_USER_PENDING });
try {
const res = await apiGetUser(address);
if (!signature) {
signature = await getAuthToken(address, dispatch);
if (!authSignature) {
authSignature = await getAuthSignature(address, dispatch);
}
const res = await apiAuthUser({
accountAddress: address,
signedMessage: authSignature.signedMessage,
rawTypedData: JSON.stringify(authSignature.rawTypedData),
});
dispatch({
type: types.AUTH_USER_FULFILLED,
payload: {
user: res.data,
token: signature,
authSignature,
},
});
} catch (err) {
@ -53,19 +65,20 @@ export function createUser(user: {
dispatch({ type: types.CREATE_USER_PENDING });
try {
const token = await getAuthToken(user.address, dispatch);
const authSignature = await getAuthSignature(user.address, dispatch);
const res = await apiCreateUser({
accountAddress: user.address,
emailAddress: user.email,
displayName: user.name,
title: user.title,
token,
signedMessage: authSignature.signedMessage,
rawTypedData: JSON.stringify(authSignature.rawTypedData),
});
dispatch({
type: types.CREATE_USER_FULFILLED,
payload: {
user: res.data,
token,
authSignature,
},
});
} catch (err) {

View File

@ -5,5 +5,5 @@ export const authPersistConfig: PersistConfig = {
key: 'auth',
storage,
version: 1,
whitelist: ['token', 'tokenAddress'],
whitelist: ['authSignature', 'authSignatureAddress'],
};

View File

@ -1,6 +1,6 @@
import types from './types';
// TODO: Use a common User type instead of this
import { TeamMember } from 'types';
import { TeamMember, AuthSignatureData } from 'types';
export interface AuthState {
user: TeamMember | null;
@ -13,10 +13,10 @@ export interface AuthState {
isCreatingUser: boolean;
createUserError: string | null;
token: string | null;
tokenAddress: string | null;
isSigningToken: boolean;
signTokenError: string | null;
authSignature: AuthSignatureData | null;
authSignatureAddress: string | null;
isSigningAuth: boolean;
signAuthError: string | null;
}
export const INITIAL_STATE: AuthState = {
@ -30,13 +30,16 @@ export const INITIAL_STATE: AuthState = {
checkedUsers: {},
isCheckingUser: false,
token: null,
tokenAddress: null,
isSigningToken: false,
signTokenError: null,
authSignature: null,
authSignatureAddress: null,
isSigningAuth: false,
signAuthError: null,
};
export default function createReducer(state: AuthState = INITIAL_STATE, action: any) {
export default function createReducer(
state: AuthState = INITIAL_STATE,
action: any,
): AuthState {
switch (action.type) {
case types.AUTH_USER_PENDING:
return {
@ -49,8 +52,8 @@ export default function createReducer(state: AuthState = INITIAL_STATE, action:
return {
...state,
user: action.payload.user,
token: action.payload.token, // TODO: Make this the real token
tokenAddress: action.payload.user.ethAddress,
authSignature: action.payload.authSignature, // TODO: Make this the real token
authSignatureAddress: action.payload.user.ethAddress,
isAuthingUser: false,
};
case types.AUTH_USER_REJECTED:
@ -70,8 +73,8 @@ export default function createReducer(state: AuthState = INITIAL_STATE, action:
return {
...state,
user: action.payload.user,
token: action.payload.token,
tokenAddress: action.payload.user.ethAddress,
authSignature: action.payload.authSignature,
authSignatureAddress: action.payload.user.ethAddress,
isCreatingUser: false,
checkedUsers: {
...state.checkedUsers,
@ -113,30 +116,30 @@ export default function createReducer(state: AuthState = INITIAL_STATE, action:
case types.SIGN_TOKEN_PENDING:
return {
...state,
token: null,
isSigningToken: true,
signTokenError: null,
authSignature: null,
isSigningAuth: true,
signAuthError: null,
};
case types.SIGN_TOKEN_FULFILLED:
return {
...state,
token: action.payload.token,
tokenAddress: action.payload.address,
isSigningToken: false,
authSignature: action.payload.authSignature,
authSignatureAddress: action.payload.address,
isSigningAuth: false,
};
case types.SIGN_TOKEN_REJECTED:
return {
...state,
isSigningToken: false,
signTokenError: action.payload,
isSigningAuth: false,
signAuthError: action.payload,
};
case types.LOGOUT:
return {
...state,
user: null,
token: null,
tokenAddress: null,
authSignature: null,
authSignatureAddress: null,
};
}
return state;

View File

@ -1,17 +1,17 @@
import { SagaIterator } from 'redux-saga';
import { select, put, all, takeEvery } from 'redux-saga/effects';
import { REHYDRATE } from 'redux-persist';
import { getAuthTokenAddress, getAuthToken } from './selectors';
import { getAuthSignature, getAuthSignatureAddress } from './selectors';
import { authUser } from './actions';
export function* authFromToken(): SagaIterator {
const address: ReturnType<typeof getAuthTokenAddress> = yield select(
getAuthTokenAddress,
const address: ReturnType<typeof getAuthSignatureAddress> = yield select(
getAuthSignatureAddress,
);
if (!address) {
return;
}
const signature: ReturnType<typeof getAuthToken> = yield select(getAuthToken);
const signature: ReturnType<typeof getAuthSignature> = yield select(getAuthSignature);
// TODO: Figure out how to type redux-saga with thunks
yield put<any>(authUser(address, signature));

View File

@ -1,4 +1,4 @@
import { AppState as S } from 'store/reducers';
export const getAuthToken = (s: S) => s.auth.token;
export const getAuthTokenAddress = (s: S) => s.auth.tokenAddress;
export const getAuthSignature = (s: S) => s.auth.authSignature;
export const getAuthSignatureAddress = (s: S) => s.auth.authSignatureAddress;

View File

@ -9,7 +9,7 @@ import { fetchProposal, fetchProposals } from 'modules/proposals/actions';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { AppState } from 'store/reducers';
import { Wei } from 'utils/units';
import { TeamMember } from 'types';
import { TeamMember, AuthSignatureData } from 'types';
type GetState = () => AppState;
@ -412,17 +412,14 @@ export function withdrawRefund(crowdFundContract: any, address: string) {
};
}
// TODO: Fill me out with all param types.
// TODO: _ will be primaryType for EIP-712
export function signData(data: any, dataTypes: any, _: string) {
// TODO: Fill out params with typed data
export function signData(data: object, dataTypes: object, primaryType: string) {
return async (dispatch: Dispatch<any>, getState: GetState) => {
dispatch({ type: types.SIGN_DATA_PENDING });
const state = getState();
const { web3, accounts } = state.web3;
// Needed for EIP-712
// const chainId = await web3.eth.net.getId();
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
const handleErr = (err: any) => {
console.error(err);
dispatch({
@ -438,28 +435,30 @@ export function signData(data: any, dataTypes: any, _: string) {
throw new Error('No web3 instance available!');
}
// TODO: This typing is hella broken
const chainId = await web3.eth.net.getId();
const rawTypedData = {
domain: {
name: 'Grant.io',
version: 1,
chainId,
},
types: {
...dataTypes,
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
],
},
message: data,
primaryType,
};
(web3.currentProvider as any).sendAsync(
{
method: 'eth_signTypedData',
params: [
Object.keys(dataTypes).map(k => ({
...dataTypes[k],
value: data[k],
})),
accounts[0],
],
// EIP-712
// params: [JSON.stringify({
// primaryType,
// domain: {
// origin: window.location.origin,
// version: 1,
// chainId,
// },
// types: dataTypes,
// message: data,
// }), accounts[0]],
method: 'eth_signTypedData_v3',
params: [accounts[0], JSON.stringify(rawTypedData)],
from: accounts[0],
},
(err: Error | undefined, res: any) => {
if (err) {
@ -469,8 +468,12 @@ export function signData(data: any, dataTypes: any, _: string) {
const msg = web3ErrorToString(res.error);
return handleErr(new Error(msg));
}
dispatch({ type: types.SIGN_DATA_FULFILLED, payload: res.result });
resolve(res.result);
const payload: AuthSignatureData = {
signedMessage: res.result,
rawTypedData,
};
dispatch({ type: types.SIGN_DATA_FULFILLED, payload });
resolve(payload);
},
);
} catch (err) {

View File

@ -40,15 +40,17 @@ export function generateAuthSignatureData(address: string) {
return {
data: { message, time },
types: {
message: {
name: 'Message Proof',
type: 'string',
},
time: {
name: 'Time',
type: 'string',
},
authorization: [
{
name: 'Message Proof',
type: 'string',
},
{
name: 'Time',
type: 'string',
},
],
},
primaryType: 'message',
primaryType: 'authorization',
};
}

4
frontend/types/api.ts Normal file
View File

@ -0,0 +1,4 @@
export interface AuthSignatureData {
signedMessage: string;
rawTypedData: any;
}

View File

@ -1,43 +1,8 @@
import { User, TeamMember } from './user';
import { SocialAccountMap, SOCIAL_TYPE, SocialInfo } from './social';
import { CreateFormState } from './create';
import { Comment, UserComment } from './comment';
import {
MILESTONE_STATE,
Milestone,
ProposalMilestone,
CreateMilestone,
} from './milestone';
import { Update } from './update';
import {
Contributor,
CrowdFund,
Proposal,
ProposalWithCrowdFund,
ProposalComments,
ProposalUpdates,
UserProposal,
} from './proposal';
export {
User,
UserComment,
UserProposal,
TeamMember,
SocialAccountMap,
SOCIAL_TYPE,
SocialInfo,
CreateFormState,
CreateMilestone,
Contributor,
MILESTONE_STATE,
Milestone,
ProposalMilestone,
CrowdFund,
Proposal,
ProposalWithCrowdFund,
Comment,
ProposalComments,
Update,
ProposalUpdates,
};
export * from './user';
export * from './social';
export * from './create';
export * from './comment';
export * from './milestone';
export * from './update';
export * from './proposal';
export * from './api';