mirror of
https://github.com/alexgo-io/stacks-puppet-node.git
synced 2026-03-30 16:45:26 +08:00
999 lines
35 KiB
Python
999 lines
35 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
from __future__ import print_function
|
|
|
|
"""
|
|
Blockstack-client
|
|
~~~~~
|
|
copyright: (c) 2014-2015 by Halfmoon Labs, Inc.
|
|
copyright: (c) 2016 by Blockstack.org
|
|
|
|
This file is part of Blockstack-client.
|
|
|
|
Blockstack-client is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
Blockstack-client is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
You should have received a copy of the GNU General Public License
|
|
along with Blockstack-client. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
import os
|
|
import keylib
|
|
import re
|
|
import posixpath
|
|
import jsonschema
|
|
from jsonschema.exceptions import ValidationError
|
|
import time
|
|
|
|
from keylib import *
|
|
|
|
import jsontokens
|
|
import urllib
|
|
import urllib2
|
|
import wallet
|
|
import config
|
|
import storage
|
|
import data
|
|
import user as user_db
|
|
from .proxy import *
|
|
|
|
from config import get_config
|
|
from .constants import CONFIG_PATH, APP_ACCOUNT_DIRNAME, BLOCKSTACK_TEST, LENGTH_MAX_NAME
|
|
from .schemas import *
|
|
from keys import HDWallet, get_pubkey_hex
|
|
|
|
# cache accounts in RAM
|
|
ACCOUNT_CACHE = {}
|
|
|
|
def app_accounts_dir(config_path=CONFIG_PATH):
|
|
"""
|
|
Get the directory that holds all app account state
|
|
"""
|
|
conf = get_config(path=config_path)
|
|
assert conf
|
|
|
|
account_dir = conf['accounts']
|
|
if posixpath.normpath(os.path.abspath(account_dir)) != posixpath.normpath(conf['accounts']):
|
|
# relative path; make absolute
|
|
account_dir = posixpath.normpath( os.path.join(os.path.dirname(config_path), account_dir) )
|
|
|
|
return account_dir
|
|
|
|
|
|
def app_account_name(user_id, app_fqu, appname):
|
|
"""
|
|
make an account name
|
|
"""
|
|
jsonschema.validate(user_id, {'type': 'string', 'pattern': OP_USER_ID_PATTERN})
|
|
jsonschema.validate(app_fqu, {'type': 'string', 'pattern': OP_NAME_PATTERN})
|
|
jsonschema.validate(appname, {'type': 'string', 'pattern': OP_URLENCODED_PATTERN})
|
|
|
|
return '{}~{}~{}'.format(user_id, app_fqu, appname)
|
|
|
|
|
|
def app_account_datastore_name(account_name):
|
|
"""
|
|
make the name of the datastore for an account
|
|
"""
|
|
return '_app_ds~{}'.format(account_name)
|
|
|
|
|
|
def app_account_parse_name(account_name):
|
|
"""
|
|
Parse an account name
|
|
Return {'user_id': ..., 'app_blockchain_id': ..., 'app_name': ...} on success
|
|
Return None on error
|
|
"""
|
|
grp = re.match("^([^~]+)~([^~]+)~([^~]+)$", account_name)
|
|
if grp is None:
|
|
return None
|
|
|
|
user_id, app_fqu, appname = grp.groups()
|
|
|
|
try:
|
|
jsonschema.validate(user_id, {'type': 'string', 'pattern': OP_USER_ID_PATTERN})
|
|
jsonschema.validate(app_fqu, {'type': 'string', 'pattern': OP_NAME_PATTERN})
|
|
jsonschema.validate(appname, {'type': 'string', 'pattern': OP_URLENCODED_PATTERN})
|
|
except ValidationError:
|
|
return None
|
|
|
|
return {'user_id': user_id, 'app_blockchain_id': app_fqu, 'app_name': appname}
|
|
|
|
|
|
def app_account_parse_datastore_name(datastore_name):
|
|
"""
|
|
Given an account datastore name, parse it.
|
|
Return {'user_id': ..., 'app_blockchain_id': ..., 'app_name': ...} on success
|
|
Return None on failure
|
|
"""
|
|
if not datastore_name.startswith('_app_ds~'):
|
|
return None
|
|
|
|
datastore_name = datastore_name[len('_app_ds~'):]
|
|
datastore_parts = app_account_parse_name(datastore_name)
|
|
return datastore_parts
|
|
|
|
|
|
def app_account_path(user_id, app_fqu, appname, config_path=CONFIG_PATH):
|
|
"""
|
|
Get the path to an app account.
|
|
An app account contains all the sensitive, persistent information
|
|
for a user to both authenticate itself to the application and for
|
|
the application to authenticate itself to the user.
|
|
"""
|
|
account_dir = app_accounts_dir(config_path=config_path)
|
|
account_id = app_account_name(user_id, appname, app_fqu)
|
|
account_path = os.path.join(account_dir, account_id + ".account")
|
|
return account_path
|
|
|
|
|
|
def app_accounts_list(config_path=CONFIG_PATH, data_pubkey_hex=None):
|
|
"""
|
|
Get the list of all accounts
|
|
Return a list of APP_ACCOUNT_SCHEMA-formatted objects
|
|
NOTE: their contents will not be verified
|
|
"""
|
|
accounts_dir = app_accounts_dir(config_path=CONFIG_PATH)
|
|
if not os.path.exists(accounts_dir) or not os.path.isdir(accounts_dir):
|
|
log.error("No app accounts directory")
|
|
return []
|
|
|
|
names = os.listdir(accounts_dir)
|
|
names = filter(lambda n: n.endswith(".account"), names)
|
|
ret = []
|
|
|
|
for name in names:
|
|
path = os.path.join( accounts_dir, name )
|
|
info = _app_load_account_path( path, data_pubkey_hex, config_path=config_path )
|
|
if 'error' in info:
|
|
continue
|
|
|
|
ret.append(info['account'])
|
|
|
|
return ret
|
|
|
|
|
|
def _app_load_account_path( account_path, data_pubkey_hex, config_path=CONFIG_PATH ):
|
|
"""
|
|
Load and return the JWT for a account, given its path
|
|
Return {'account: account jwt, 'account_token': token} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
jwt = None
|
|
try:
|
|
with open(account_path, "r") as f:
|
|
jwt = f.read()
|
|
|
|
except:
|
|
log.error("Failed to load {}".format(account_path))
|
|
return {'error': 'Failed to read account'}
|
|
|
|
# verify
|
|
if data_pubkey_hex is not None:
|
|
verifier = jsontokens.TokenVerifier()
|
|
valid = verifier.verify( jwt, str(data_pubkey_hex) )
|
|
if not valid:
|
|
return {'error': 'Failed to verify JWT data'}
|
|
|
|
data = jsontokens.decode_token( jwt )
|
|
jsonschema.validate( data['payload'], APP_ACCOUNT_SCHEMA )
|
|
return {'account': data['payload'], 'account_token': jwt}
|
|
|
|
|
|
def app_load_account( user_id, app_fqu, appname, user_pubkey_hex, config_path=CONFIG_PATH):
|
|
"""
|
|
Load the app account for the given (user_id, app owner name, appname) triple
|
|
user_pubkey_hex is the user's public key.
|
|
|
|
Return {'account: jwt, 'account_token': token} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
global ACCOUNT_CACHE
|
|
|
|
account_name = app_account_name(user_id, app_fqu, appname)
|
|
if ACCOUNT_CACHE.has_key(account_name):
|
|
log.debug("Account {} is cached".format(account_name))
|
|
return ACCOUNT_CACHE[account_name]
|
|
|
|
path = app_account_path( user_id, app_fqu, appname, config_path=config_path)
|
|
res = _app_load_account_path( path, user_pubkey_hex, config_path=config_path )
|
|
if 'error' in res:
|
|
return res
|
|
|
|
ACCOUNT_CACHE[account_name] = res
|
|
return res
|
|
|
|
|
|
def app_find_accounts( user_id=None, app_fqu=None, appname=None, user_pubkey_hex=None, config_path=CONFIG_PATH ):
|
|
"""
|
|
Find the list of accounts for a particular application
|
|
Return the list of account IDs found
|
|
"""
|
|
infos = app_accounts_list(config_path=CONFIG_PATH, data_pubkey_hex=user_pubkey_hex)
|
|
|
|
if user_id is not None:
|
|
infos = filter( lambda ai: ai['user_id'] == user_id, infos )
|
|
|
|
if app_fqu is not None:
|
|
infos = filter( lambda ai: ai['name'] == app_fqu, infos )
|
|
|
|
if appname is not None:
|
|
infos = filter( lambda ai: ai['appname'] == appname, infos )
|
|
|
|
return infos
|
|
|
|
|
|
def app_account_get_privkey( user_data_privkey_hex, app_account, config_path=CONFIG_PATH ):
|
|
"""
|
|
Given the owning user's private key and an app account structure, calculate the private key
|
|
for the account.
|
|
|
|
hdpath is USER_PRIVKEY/0'/ACCOUNT_INDEX'
|
|
|
|
Return the private key
|
|
"""
|
|
hdwallet_parent = HDWallet( hex_privkey=user_data_privkey_hex, config_path=config_path)
|
|
app_account_privkey_parent = hdwallet_parent.get_child_privkey( index=0 )
|
|
|
|
hdwallet = HDWallet( hex_privkey=app_account_privkey_parent, config_path=config_path )
|
|
app_account_privkey = hdwallet.get_child_privkey( index=app_account['privkey_index'] )
|
|
|
|
return app_account_privkey
|
|
|
|
|
|
def app_make_account_info( app_fqu, appname, api_methods, user_id, user_pubkey_hex, privkey_index, session_lifetime ):
|
|
"""
|
|
Create account information
|
|
"""
|
|
|
|
info = {
|
|
'name': app_fqu,
|
|
'appname': appname,
|
|
'methods': api_methods,
|
|
'user_id': user_id,
|
|
'public_key': user_pubkey_hex,
|
|
'privkey_index': privkey_index,
|
|
'session_lifetime': session_lifetime
|
|
}
|
|
return info
|
|
|
|
|
|
def app_make_account( user_info, user_privkey_hex, app_fqu, appname, api_methods, config_path=CONFIG_PATH, session_lifetime=3600*24*7):
|
|
"""
|
|
Create a new application account, and an associated store.
|
|
Return {'account': jwt, 'account_token': token} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
|
|
next_privkey_index_info = data.next_privkey_index(user_privkey_hex, user_info['global'],
|
|
blockchain_id=user_info.get('blockchain_id', None),
|
|
config_path=config_path)
|
|
if 'error' in next_privkey_index_info:
|
|
return next_privkey_index_info
|
|
|
|
privkey_index = next_privkey_index_info['index']
|
|
|
|
hdwallet = HDWallet( hex_privkey=user_privkey_hex )
|
|
app_account_privkey = hdwallet.get_child_privkey( index=privkey_index )
|
|
|
|
info = app_make_account_info( app_fqu, appname, api_methods, user_info['user_id'], get_pubkey_hex(app_account_privkey), privkey_index, session_lifetime )
|
|
|
|
# sign
|
|
signer = jsontokens.TokenSigner()
|
|
token = signer.sign( info, user_privkey_hex )
|
|
return {'account': info, 'account_token': token}
|
|
|
|
|
|
def app_store_account( token, config_path=CONFIG_PATH ):
|
|
"""
|
|
Store the app account token.
|
|
The token is an encoded JWT.
|
|
Return {'status': True} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
global ACCOUNT_CACHE
|
|
|
|
# verify that this is a well-formed account
|
|
acct_jwt = jsontokens.decode_token(token)
|
|
acct = acct_jwt['payload']
|
|
jsonschema.validate(acct, APP_ACCOUNT_SCHEMA)
|
|
|
|
user_id = acct['user_id']
|
|
app_fqu = acct['name']
|
|
appname = acct['appname']
|
|
|
|
path = app_account_path( user_id, app_fqu, appname, config_path=config_path)
|
|
try:
|
|
pathdir = os.path.dirname(path)
|
|
if not os.path.exists(pathdir):
|
|
os.makedirs(pathdir)
|
|
|
|
with open(path, "w") as f:
|
|
f.write(token)
|
|
|
|
except:
|
|
log.error("Failed to store {}".format(path))
|
|
return {'error': 'Failed to store account'}
|
|
|
|
account_name = app_account_name(user_id, app_fqu, appname)
|
|
if ACCOUNT_CACHE.has_key(account_name):
|
|
del ACCOUNT_CACHE[account_name]
|
|
|
|
return {'status': True}
|
|
|
|
|
|
def app_delete_account( user_id, app_fqu, appname, config_path=CONFIG_PATH):
|
|
"""
|
|
Remove an app account for a given (uuser_id, app_fqu, appname) pair.
|
|
Return {'status': True} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
global ACCOUNT_CACHE
|
|
|
|
path = app_account_path( user_id, app_fqu, appname, config_path=config_path)
|
|
if not os.path.exists(path):
|
|
return {'error': 'No such account'}
|
|
|
|
try:
|
|
os.unlink(path)
|
|
except:
|
|
log.error("Failed to remove {}".format(path))
|
|
return {'error': 'Failed to remove account'}
|
|
|
|
account_name = app_account_name(user_id, app_fqu, appname)
|
|
if ACCOUNT_CACHE.has_key(account_name):
|
|
del ACCOUNT_CACHE[account_name]
|
|
|
|
return {'status': True}
|
|
|
|
|
|
def app_make_session( app_account, data_privkey_hex, config_path=CONFIG_PATH ):
|
|
"""
|
|
Make a session JWT for this application.
|
|
Return {'session': session jwt, 'session_token': session token} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
conf = get_config(path=config_path)
|
|
default_lifetime = conf.get('default_session_lifetime', 1e80)
|
|
|
|
privkey = app_account_get_privkey( data_privkey_hex, app_account )
|
|
|
|
ses = {
|
|
'name': app_account['name'],
|
|
'appname': app_account['appname'],
|
|
'user_id': app_account['user_id'],
|
|
'methods': app_account['methods'],
|
|
'public_key': get_pubkey_hex(privkey),
|
|
'timestamp': int(time.time()),
|
|
'expires': int(time.time() + min(default_lifetime, app_account['session_lifetime']))
|
|
}
|
|
|
|
jsonschema.validate(ses, APP_SESSION_SCHEMA)
|
|
|
|
signer = jsontokens.TokenSigner()
|
|
session_token = signer.sign( ses, data_privkey_hex )
|
|
session = jsontokens.decode_token(session_token)
|
|
|
|
return {'session': session, 'session_token': session_token}
|
|
|
|
|
|
def app_verify_session( app_session_token, data_pubkey_hex, config_path=CONFIG_PATH ):
|
|
"""
|
|
Verify and decode a JWT app session token.
|
|
The session is valid if the signature matches and the token is not expired.
|
|
Return the decoded session token payload on success
|
|
Return None on error
|
|
"""
|
|
pubkey = str(data_pubkey_hex)
|
|
verifier = jsontokens.TokenVerifier()
|
|
valid = verifier.verify( app_session_token, pubkey )
|
|
if not valid:
|
|
log.debug("Failed to verify with {}".format(pubkey))
|
|
return None
|
|
|
|
session_jwt = jsontokens.decode_token(app_session_token)
|
|
session = session_jwt['payload']
|
|
|
|
# must match session structure
|
|
try:
|
|
jsonschema.validate(session, APP_SESSION_SCHEMA)
|
|
except ValidationError as ve:
|
|
if BLOCKSTACK_TEST:
|
|
log.exception(ve)
|
|
|
|
return None
|
|
|
|
user_id = session['user_id']
|
|
app_fqu = session['name']
|
|
appname = session['appname']
|
|
account_path = app_account_path(user_id, app_fqu, appname, config_path=config_path)
|
|
|
|
# account must exist
|
|
if not os.path.exists( app_account_path(user_id, app_fqu, appname, config_path=config_path) ):
|
|
log.debug("No such account")
|
|
return None
|
|
|
|
# session must not be expired
|
|
if session['expires'] < time.time():
|
|
log.debug("Token is expired")
|
|
return None
|
|
|
|
return session
|
|
|
|
|
|
def _get_url_nonce( config_path=CONFIG_PATH ):
|
|
"""
|
|
Get the current URL nonce
|
|
Return None if we can't read from the nonce file
|
|
"""
|
|
nonce = 0
|
|
dirp = app_accounts_dir(config_path=config_path)
|
|
nonce_path = os.path.join(dirp, ".signin_nonce")
|
|
if os.path.exists(nonce_path):
|
|
try:
|
|
with open(nonce_path, "r") as f:
|
|
nonce_str = f.read().strip()
|
|
nonce = int(nonce_str)
|
|
except:
|
|
return None
|
|
|
|
return nonce
|
|
|
|
|
|
def _make_url_nonce( config_path=CONFIG_PATH ):
|
|
"""
|
|
Make a one-time-use nonce for a signed URL
|
|
"""
|
|
dirp = app_accounts_dir(config_path=config_path)
|
|
if not os.path.exists(dirp):
|
|
os.makedirs(dirp)
|
|
|
|
nonce_path = os.path.join(dirp, ".signin_nonce")
|
|
nonce = _get_url_nonce(config_path=config_path)
|
|
if nonce is None:
|
|
# couldn't read
|
|
log.error("Failed to read url nonce file {}".format(nonce_path))
|
|
return None
|
|
|
|
nonce = nonce + 1
|
|
|
|
try:
|
|
with open(nonce_path, "w") as f:
|
|
f.write("{}".format(nonce))
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
|
|
except:
|
|
log.error("Failed to store url nonce to {}".format(nonce_path))
|
|
return None
|
|
|
|
return nonce
|
|
|
|
|
|
def _url_qs_append( url, qs_extra ):
|
|
"""
|
|
Append the query information to the URL
|
|
"""
|
|
url_info = urllib2.urlparse.urlparse(url)
|
|
if len(url_info.query) > 0:
|
|
qs_extra = url_info.query + '&' + qs_extra
|
|
|
|
new_url_info = urllib2.urlparse.ParseResult( scheme=url_info.scheme, netloc=url_info.netloc, path=url_info.path, params=url_info.params, query=qs_extra, fragment=url_info.fragment )
|
|
url = urllib2.urlparse.urlunparse( new_url_info )
|
|
return url
|
|
|
|
|
|
def app_sign_url( url, data_privkey, config_path=CONFIG_PATH ):
|
|
"""
|
|
Sign a URL and append the signature to its query string
|
|
Return the signed URL on success
|
|
Return None on error
|
|
"""
|
|
# normalize
|
|
url = urllib2.urlparse.urlunparse( urllib2.urlparse.urlparse(url) )
|
|
|
|
nonce = _make_url_nonce(config_path=config_path)
|
|
if nonce is None:
|
|
log.error("Failed to make URL nonce")
|
|
return None
|
|
|
|
url = _url_qs_append( url, 'nonce={}'.format(nonce) )
|
|
|
|
# sign nonce too
|
|
sig = storage.sign_raw_data(url, data_privkey)
|
|
sigurl = urllib.quote(sig)
|
|
|
|
log.debug("Sign '{}'".format(url))
|
|
url = _url_qs_append( url, 'sig={}'.format(sigurl) )
|
|
return url
|
|
|
|
|
|
def app_verify_url( url, data_pubkey, config_path=CONFIG_PATH ):
|
|
"""
|
|
Verify a URL and strip the signature.
|
|
Return {'url': stripped verified URl, 'nonce': nonce} on success
|
|
Return None on error
|
|
"""
|
|
url_info = urllib2.urlparse.urlparse(url)
|
|
qs_parts = urllib2.urlparse.parse_qsl(url_info.query, keep_blank_values=1, strict_parsing=1)
|
|
if len(qs_parts) == 0:
|
|
# no sig
|
|
log.debug("Invalid URL; no sig=")
|
|
return None
|
|
|
|
# must be exactly one signature and exactly one nonce
|
|
sigb64 = None
|
|
nonce = None
|
|
for (qs_varname, qs_value) in qs_parts:
|
|
if qs_varname == 'sig':
|
|
if sigb64 is not None:
|
|
# duplicate
|
|
log.debug("Duplicate sig=")
|
|
return None
|
|
|
|
try:
|
|
# must be base64 string
|
|
sig = urllib.unquote(qs_value)
|
|
base64.b64decode(sig)
|
|
except Exception as e:
|
|
log.debug("Not a base64-encoded signature: {}".format(qs_value))
|
|
return None
|
|
|
|
sigb64 = urllib.unquote(qs_value)
|
|
|
|
elif qs_varname == 'nonce':
|
|
if nonce is not None:
|
|
# duplicate
|
|
log.debug("Duplicate nonce=")
|
|
return None
|
|
|
|
try:
|
|
nonce = int(qs_value)
|
|
except:
|
|
log.debug("Invalid nonce")
|
|
return None
|
|
|
|
new_query_parts = []
|
|
for (qs_varname, qs_value) in qs_parts:
|
|
if qs_varname in ['sig']:
|
|
continue
|
|
|
|
new_query_parts.append( '{}={}'.format(urllib.quote(qs_varname), urllib.quote(qs_value)) )
|
|
|
|
new_query = '&'.join(new_query_parts)
|
|
orig_url_info = urllib2.urlparse.ParseResult( scheme=url_info.scheme, netloc=url_info.netloc, path=url_info.path, params=url_info.params, query=new_query, fragment=url_info.fragment )
|
|
orig_url = urllib2.urlparse.urlunparse( orig_url_info )
|
|
|
|
log.debug("Verify '{}'".format(orig_url))
|
|
res = storage.verify_raw_data(orig_url, data_pubkey, sigb64 )
|
|
if not res:
|
|
log.debug("Failed to verify URL signature")
|
|
return None
|
|
|
|
# was signed by us, but is it fresh?
|
|
cur_nonce = _get_url_nonce(config_path=config_path)
|
|
if cur_nonce is None:
|
|
# I/O error
|
|
log.error("Failed to read nonce file")
|
|
return None
|
|
|
|
if nonce < cur_nonce:
|
|
log.error("Stale URL: expected nonce >= {}, got {}".format(cur_nonce, nonce))
|
|
return None
|
|
|
|
return {'url': orig_url, 'nonce': nonce}
|
|
|
|
|
|
def app_url_auth_signin( app_fqu, appname, url_payload, data_privkey, config_path=CONFIG_PATH ):
|
|
"""
|
|
Make a URL that resolves to the signin page. The URL will be signed by the daemon and will be
|
|
one-time-use, so other apps can't redirect users to the sign-in page.
|
|
|
|
A GET on this URL should load the sign-in page, so the user can create a session.
|
|
"""
|
|
config = get_config(path=config_path)
|
|
qs = "&".join(["{}={}".format(k,v) for (k, v) in url_payload.items()])
|
|
if len(qs) > 0:
|
|
qs = "?{}".format(qs)
|
|
|
|
url = "http://localhost:{}/api/v1/auth/signin/{}/{}{}".format(config['api_endpoint_port'], app_fqu, appname, qs)
|
|
return app_sign_url(url, data_privkey, config_path=config_path)
|
|
|
|
|
|
def app_url_auth_allow_deny( app_fqu, appname, url_payload, data_privkey, config_path=CONFIG_PATH ):
|
|
"""
|
|
Make a URL that resolves to the page that asks whether or not
|
|
to create an account.
|
|
The URL will be signed, so apps can't direct users to this page.
|
|
|
|
A GET on this URL should load the account-creation page, so we can make an account.
|
|
"""
|
|
config = get_config(path=config_path)
|
|
qs = "&".join(["{}={}".format(k,v) for (k, v) in url_payload.items()])
|
|
if len(qs) > 0:
|
|
qs = '?{}'.format(qs)
|
|
|
|
url = "http://localhost:{}/api/v1/auth/allowdeny/{}/{}{}".format(config['api_endpoint_port'], app_fqu, appname, qs)
|
|
return app_sign_url(url, data_privkey, config_path=config_path)
|
|
|
|
|
|
def app_url_auth_create_account( user_id, app_fqu, appname, url_payload, data_privkey, config_path=CONFIG_PATH ):
|
|
"""
|
|
Make a URL that, when GET'ed, will create an application account. A GET on this URL creates the account,
|
|
and redirects the GET'er to a URL with the session (via app_url_auth_finish)
|
|
|
|
Returns the URL
|
|
"""
|
|
config = get_config(path=config_path)
|
|
qs = "&".join(["{}={}".format(k,v) for (k, v) in url_payload.items()])
|
|
if len(qs) > 0:
|
|
qs = '?{}'.format(qs)
|
|
|
|
url = "http://localhost:{}/api/v1/auth/newaccount/{}/{}/{}{}".format(config['api_endpoint_port'], user_id, app_fqu, appname, qs)
|
|
return app_sign_url(url, data_privkey, config_path=config_path)
|
|
|
|
|
|
def app_url_auth_load_account( user_id, app_fqu, appname, url_payload, data_privkey, config_path=CONFIG_PATH ):
|
|
"""
|
|
Make a URL that, when GET'ed, will load an account. A GET on this URL loads the account,
|
|
and redirects the GET'er to a URL with the session (via app_url_auth_finish)
|
|
|
|
Returns the URL
|
|
"""
|
|
config = get_config(path=config_path)
|
|
qs = '&'.join(['{}={}'.format(k, v) for (k, v) in url_payload.items()])
|
|
if len(qs) > 0:
|
|
qs = '?{}'.format(qs)
|
|
|
|
url = "http://localhost:{}/api/v1/auth/loadaccount/{}/{}/{}{}".format(config['api_endpoint_port'], user_id, app_fqu, appname, qs)
|
|
return app_sign_url(url, data_privkey, config_path=config_path)
|
|
|
|
|
|
def app_url_auth_abort(config_path=CONFIG_PATH):
|
|
"""
|
|
Make a URL that aborts the authentication
|
|
"""
|
|
config = get_config(path=config_path)
|
|
url = "http://localhost:{}/home".format(config['api_endpoint_port'])
|
|
return url
|
|
|
|
|
|
def app_url_auth_finish( url_payload, data_privkey, session_token, config_path=CONFIG_PATH ):
|
|
"""
|
|
Make a URL that redirects back to the app, passing the session JWT
|
|
as part of the query string. The URL will be signed, so apps can't
|
|
generate this URL without the daemon's blessing.
|
|
|
|
A GET on this URL should load the app's index.html file (or similar).
|
|
"""
|
|
config = get_config(path=config_path)
|
|
url_payload['session'] = session_token
|
|
|
|
qs = '?' + '&'.join(['{}={}'.format(k, v) for (k, v) in url_payload.items()])
|
|
|
|
url = 'http://localhost:{}/index.html{}'.format(config['api_endpoint_port'], qs)
|
|
return app_sign_url(url, data_privkey, config_path=config_path)
|
|
|
|
|
|
def app_auth_begin( app_fqu, appname, app_url_payload, data_privkey, config_path=CONFIG_PATH ):
|
|
"""
|
|
Make an authentication URL to redirect the app-loader's request to run the app.
|
|
|
|
If an app account exists, then use it to generate a session JWT
|
|
and return the URL for the daemon to redirect the requester.
|
|
|
|
If an app account does not exist, then we need to determine what capabilities
|
|
the app needs and ask the user to create the account. In this case, reply
|
|
a URL that, when queried, will load up page to ask the user if they want to create an account.
|
|
|
|
Return the URL
|
|
"""
|
|
assert data_privkey, "Could not look up data private key"
|
|
|
|
accts = app_find_accounts( app_fqu=app_fqu, appname=appname, config_path=config_path )
|
|
if len(accts) == 0:
|
|
# app is not known to us.
|
|
# redirect to allow/deny page.
|
|
url = app_url_auth_allow_deny( app_fqu, appname, app_url_payload, data_privkey, config_path=config_path )
|
|
return url
|
|
|
|
else:
|
|
# we're trying to sign in.
|
|
# redirect to sign-in page
|
|
url = app_url_auth_signin( app_fqu, appname, app_url_payload, data_privkey, config_path=config_path )
|
|
return url
|
|
|
|
|
|
def app_auth_finish( user_id, app_fqu, appname, data_pubkey, config_path=CONFIG_PATH):
|
|
"""
|
|
Finish authenticating.
|
|
Load up and return a session JWT
|
|
Return {'session': session} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
|
|
# load up the account...
|
|
info = app_load_account( user_id, app_fqu, appname, data_pubkey, config_path=config_path)
|
|
if 'error' in info:
|
|
log.error("Failed to load app account for {}".format(appname))
|
|
return {'error': 'Failed to load app info'}
|
|
|
|
info = info['account']
|
|
|
|
# generate a session JWT
|
|
ses = app_make_session( info, config_path=config_path )
|
|
return ses
|
|
|
|
|
|
def app_publish( name, appname, app_method_list, app_index_uris, app_index_file, app_driver_hints=[], data_privkey=None, proxy=None, wallet_keys=None, config_path=CONFIG_PATH ):
|
|
"""
|
|
Instantiate an application.
|
|
* replicate the (opaque) app index file to "index.html" to each URL in app_uris
|
|
* replicate the list of URIs and the list of methods to ".blockstack" via each of the client's storage drivers.
|
|
* the index file will be located at "$name:$appname/index.html"
|
|
* the .blockstack file will be located at "$name:$appname/.blockstack"
|
|
|
|
This succeeds even if the app already exists (in which case,
|
|
it will be overwritten). This method is idempotent, so it
|
|
can be retried on failure.
|
|
|
|
data_privkey should be the publisher's private key (i.e. their data key)
|
|
name should be the blockchain ID that points to data_pubkey
|
|
|
|
Return {'status': True, 'fq_data_id': index file's fully-qualified data ID} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
|
|
proxy = get_default_proxy() if proxy is None else proxy
|
|
|
|
# replicate configuration data (method list and app URIs)
|
|
app_cfg = {
|
|
'index_uris': app_index_uris,
|
|
'api_methods': app_method_list,
|
|
'driver_hints': app_driver_hints,
|
|
}
|
|
|
|
jsonschema.validate(app_cfg, APP_CONFIG_SCHEMA)
|
|
|
|
config_data_id = '{}/.blockstack'.format(appname)
|
|
data_id = storage.make_fq_data_id(name, config_data_id)
|
|
res = data.put_mutable(data_id, app_cfg, blockchain_id=name, data_privkey=data_privkey, wallet_keys=wallet_keys, config_path=config_path, fully_qualified_data_id=True)
|
|
if 'error' in res:
|
|
log.error('Failed to replicate application configuration {}: {}'.format(config_data_id, res['error']))
|
|
return {'error': 'Failed to replicate application config'}
|
|
|
|
# what drivers to use for the index file?
|
|
urls = user_db.urls_from_uris(app_index_uris)
|
|
driver_names = []
|
|
|
|
for url in urls:
|
|
drivers = storage.get_drivers_for_url(url)
|
|
driver_names += [d.__name__ for d in drivers]
|
|
|
|
driver_names = list(set(driver_names))
|
|
index_data_id = "{}/index.html".format(appname)
|
|
|
|
# replicate app index file (at least one must succeed)
|
|
# NOTE: the publisher is free to use alternative URIs that are not supported; they'll just be ignored.
|
|
data_id = storage.make_fq_data_id(name, index_data_id)
|
|
res = data.put_mutable( data_id, app_index_file, blockchain_id=name, data_privkey=data_privkey, storage_drivers=driver_names, wallet_keys=wallet_keys, config_path=config_path, fully_qualified_data_id=True)
|
|
if 'error' in res:
|
|
log.error("Failed to replicate application index file to {}: {}".format(",".join(urls), res['error']))
|
|
return {'error': 'Failed to replicate index file'}
|
|
|
|
return {'status': True, 'fq_data_id': storage.make_fq_data_id(name, index_data_id)}
|
|
|
|
|
|
def app_get_config( name, appname, data_pubkey=None, proxy=None, config_path=CONFIG_PATH ):
|
|
"""
|
|
Get application configuration bundle.
|
|
|
|
data_pubkey should be the publisher's public key.
|
|
|
|
Return {'status': True, 'config': config} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
|
|
proxy = get_default_proxy() if proxy is None else proxy
|
|
|
|
# go get config
|
|
config_data_id = '{}/.blockstack'.format(appname)
|
|
data_id = storage.make_fq_data_id(name, config_data_id)
|
|
res = data.get_mutable( data_id, data_pubkey=data_pubkey, proxy=proxy, config_path=config_path, blockchain_id=name, fully_qualified_data_id=True )
|
|
if 'error' in res:
|
|
log.error("Failed to get application config file {}: {}".format(config_data_id, res['error']))
|
|
return res
|
|
|
|
app_cfg = None
|
|
try:
|
|
app_cfg = res['data']
|
|
jsonschema.validate(app_cfg, APP_CONFIG_SCHEMA)
|
|
except ValidationError as ve:
|
|
if BLOCKSTACK_TEST:
|
|
log.exception(ve)
|
|
|
|
log.error("Invalid application config file {}".format(config_data_id))
|
|
return {'error': 'Invalid application config'}
|
|
|
|
return {'status': True, 'config': app_cfg}
|
|
|
|
|
|
def app_make_resource_data_id( name, appname, res_name ):
|
|
"""
|
|
Make a fully-qualified application resource data ID
|
|
"""
|
|
res_data_id = '{}/{}'.format(appname, res_name)
|
|
fq_res_data_id = storage.make_fq_data_id(name, res_data_id)
|
|
return fq_res_data_id
|
|
|
|
|
|
def app_get_index_file( name, appname, app_config=None, data_pubkey=None, proxy=None, config_path=CONFIG_PATH ):
|
|
"""
|
|
Get the application's index file.
|
|
Follows the URLs in the app_config structure (from app_get_config)
|
|
Return {status': True, 'index_file': index_file_text} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
proxy = get_default_proxy() if proxy is None else proxy
|
|
res_data_id = '{}/index.html'.format(appname)
|
|
|
|
if app_config is None:
|
|
app_config = app_get_config(name, appname, data_pubkey=data_pubkey, proxy=proxy, config_path=CONFIG_PATH )
|
|
if 'error' in app_config:
|
|
log.error("Failed to load application config: {}".format(app_config['error']))
|
|
return {'error': 'Failed to load app config'}
|
|
|
|
app_config = app_config['config']
|
|
|
|
urls = user_db.urls_from_uris( app_config['index_uris'] )
|
|
data_id = storage.make_fq_data_id(name, res_data_id)
|
|
res = data.get_mutable( data_id, data_pubkey=data_pubkey, proxy=proxy, config_path=config_path, urls=urls, blockchain_id=name, fully_qualified_data_id=True )
|
|
if 'error' in res:
|
|
log.error("Failed to get index file: {}".format(res['error']))
|
|
return {'error': 'Failed to load index'}
|
|
|
|
return {'status': True, 'index_file': res['data']}
|
|
|
|
|
|
def app_get_resource( name, appname, res_name, app_config=None, data_pubkey=None, proxy=None, config_path=CONFIG_PATH ):
|
|
"""
|
|
Get a named application resource from mutable storage
|
|
|
|
data_pubkey should be the publisher's public key
|
|
|
|
If app_config is not None, then the driver hints will be honored.
|
|
|
|
Return {'status': True, 'res': resource} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
|
|
proxy = get_default_proxy() if proxy is None else proxy
|
|
|
|
res_data_id = '{}/{}'.format(appname, res_name)
|
|
fq_res_data_id = storage.make_fq_data_id( name, res_data_id )
|
|
|
|
urls = None
|
|
if app_config is not None:
|
|
# use driver hints
|
|
driver_hints = app_config['driver_hints']
|
|
urls = storage.get_driver_urls( fq_res_data_id, storage.get_storage_handlers() )
|
|
|
|
data_id = storage.make_fq_data_id(name, res_data_id)
|
|
res = data.get_mutable( data_id, data_pubkey=data_pubkey, proxy=proxy, config_path=config_path, urls=urls, blockchain_id=name, fully_qualified_data_id=True )
|
|
if 'error' in res:
|
|
log.error("Failed to get resource {}: {}".format(fq_res_data_id, res['error']))
|
|
return {'error': 'Failed to load resource'}
|
|
|
|
return {'status': True, 'res': res['data']}
|
|
|
|
|
|
def app_put_resource( name, appname, res_name, res_data, app_config=None, data_privkey=None, proxy=None, wallet_keys=None, config_path=CONFIG_PATH ):
|
|
"""
|
|
Store data to a named application resource in mutable storage.
|
|
|
|
data_privkey should be the publisher's private key
|
|
name should be a blockchain ID that points to the public key
|
|
|
|
if app_config is not None, then the driver hints will be honored.
|
|
|
|
Return {'status': True, 'version': ...} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
|
|
proxy = get_default_proxy() if proxy is None else proxy
|
|
|
|
res_data_id = '{}/{}'.format(appname, res_name)
|
|
fq_res_data_id = storage.make_fq_data_id( name, res_data_id )
|
|
|
|
driver_hints = None
|
|
if app_config is not None:
|
|
# use driver hints
|
|
driver_hints = app_config['driver_hints']
|
|
|
|
data_id = storage.make_fq_data_id(name, res_data_id)
|
|
res = data.put_mutable(data_id, res_data, blockchain_id=name, data_privkey=data_privkey, proxy=proxy, storage_drivers=driver_hints, wallet_keys=wallet_keys, config_path=CONFIG_PATH, fully_qualified_data_id=True)
|
|
if 'error' in res:
|
|
log.error("Failed to store resource {}: {}".format(fq_res_data_id, res['error']))
|
|
return {'error': 'Failed to store resource'}
|
|
|
|
return {'status': True, 'version': res['version']}
|
|
|
|
|
|
def app_unpublish( name, appname, force=False, data_privkey=None, app_config=None, wallet_keys=None, proxy=None, config_path=CONFIG_PATH ):
|
|
"""
|
|
Unpublish an application
|
|
Deletes its config and index.
|
|
Does NOT delete its resources.
|
|
Does NOT delete user data.
|
|
|
|
if force is True, then we will try to delete the app state even if we can't load the app config
|
|
WARNING: force can be dangerous, since it can delete data via drivers that were never meant for this app. Use with caution!
|
|
|
|
Return {'status': True, 'app_config': ..., 'retry': ...} on success. If retry is True, then retry this method with the given app_config
|
|
Return {'error': ...} on error
|
|
"""
|
|
|
|
proxy = get_default_proxy() if proxy is None else proxy
|
|
|
|
if app_config is None:
|
|
# find out where to delete from
|
|
data_pubkey = None
|
|
if data_privkey is not None:
|
|
data_pubkey = get_pubkey_hex(str(data_privkey))
|
|
|
|
app_config = app_get_config(name, appname, data_pubkey=data_pubkey, proxy=proxy, config_path=CONFIG_PATH )
|
|
if 'error' in app_config:
|
|
if not force:
|
|
log.error("Failed to load app config for {}:{}".format(name, appname))
|
|
return {'error': 'Failed to load app config'}
|
|
else:
|
|
# keep going
|
|
app_config = None
|
|
log.warning("Failed to load app config, but proceeding at caller request")
|
|
|
|
config_data_id = '{}/.blockstack'.format(appname)
|
|
index_data_id = "{}/index.html".format(appname)
|
|
|
|
storage_drivers = None
|
|
if app_config is not None:
|
|
# only use the ones we have to
|
|
urls = user_db.urls_from_uris(app_config['index_uris'])
|
|
driver_names = []
|
|
|
|
for url in urls:
|
|
drivers = storage.get_drivers_for_url(url)
|
|
driver_names += [d.__name__ for d in drivers]
|
|
|
|
storage_drivers = list(set(driver_names))
|
|
|
|
ret = {}
|
|
|
|
# delete the index
|
|
data_id = '{}.{}'.format(name, index_data_id)
|
|
res = data.delete_mutable( data_id, data_privkey=data_privkey, proxy=proxy, wallet_keys=wallet_keys, delete_version=False, storage_drivers=storage_drivers )
|
|
if 'error' in res:
|
|
log.warning("Failed to delete index file {}".format(index_data_id))
|
|
ret['app_config'] = app_config
|
|
ret['retry'] = True
|
|
|
|
# delete the config
|
|
data_id = '{}.{}'.format(name, config_data_id)
|
|
res = data.delete_mutable( data_id, data_privkey=data_privkey, proxy=proxy, wallet_keys=wallet_keys, delete_version=False )
|
|
if 'error' in res:
|
|
log.warning("Failed to delete config file {}".format(config_data_id))
|
|
if not ret.has_key('app_config'):
|
|
ret['app_config'] = app_config
|
|
|
|
ret['retry'] = True
|
|
|
|
ret['status'] = True
|
|
return ret
|
|
|
|
|